Repository: vaadin/vaadin-router Branch: main Commit: 74684792d0bd Files: 222 Total size: 655.0 KB Directory structure: gitextract_suxs7xzw/ ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── docs.yml │ └── validation.yml ├── .gitignore ├── .prettierrc.json ├── .run/ │ ├── All tests.run.xml │ └── Template Karma.run.xml ├── .stylelintrc.json ├── LICENSE ├── README.md ├── analysis.json ├── demo/ │ ├── .eslintrc.json │ ├── @debug/ │ │ ├── index.html │ │ └── index.ts │ ├── @helpers/ │ │ ├── common.css │ │ ├── common.ts │ │ ├── iframe.script.ts │ │ ├── nested-styles.css │ │ ├── page.css │ │ ├── shared-styles.css │ │ ├── theme-controller.ts │ │ ├── vaadin-demo-code-snippet-file.css │ │ ├── vaadin-demo-code-snippet-file.ts │ │ ├── vaadin-demo-code-snippet.css │ │ ├── vaadin-demo-code-snippet.ts │ │ ├── vaadin-demo-layout.css │ │ ├── vaadin-demo-layout.ts │ │ ├── vaadin-presentation-addressbar.css │ │ ├── vaadin-presentation-addressbar.ts │ │ ├── vaadin-presentation.css │ │ ├── vaadin-presentation.ts │ │ ├── x-breadcrumbs.ts │ │ ├── x-home-view.ts │ │ ├── x-image-view.css │ │ ├── x-image-view.ts │ │ ├── x-knowledge-base.ts │ │ ├── x-login-view.ts │ │ ├── x-not-found-view.ts │ │ ├── x-profile-view.ts │ │ ├── x-user-list.ts │ │ ├── x-user-not-found-view.ts │ │ ├── x-user-numeric-view.ts │ │ └── x-user-profile.ts │ ├── animated-transitions/ │ │ ├── d1/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── styles.css │ │ ├── d2/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ ├── styles.css │ │ │ └── x-wrapper.ts │ │ ├── index.html │ │ └── index.ts │ ├── code-splitting/ │ │ ├── d1/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── user.bundle.ts │ │ ├── d2/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── user-routes.ts │ │ ├── index.html │ │ └── index.ts │ ├── getting-started/ │ │ ├── d1/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── index.html │ │ ├── index.ts │ │ └── snippets/ │ │ ├── s1.html │ │ ├── s2.ts │ │ └── s4.ts │ ├── index.html │ ├── index.ts │ ├── lifecycle-callback/ │ │ ├── d1/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-countdown.ts │ │ ├── d2/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-friend.ts │ │ ├── d3/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ ├── x-user-deleted.ts │ │ │ └── x-user-manage.ts │ │ ├── d4/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ ├── x-autosave-view.ts │ │ │ └── x-main-page.ts │ │ ├── d5/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d6/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── index.html │ │ ├── index.ts │ │ └── snippets/ │ │ ├── my-view-with-after-enter.ts │ │ ├── my-view-with-after-leave.ts │ │ ├── my-view-with-before-enter.ts │ │ └── my-view-with-before-leave.ts │ ├── navigation-trigger/ │ │ ├── d1/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d2/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d3/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d4/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── index.html │ │ └── index.ts │ ├── redirect/ │ │ ├── d1/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d2/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-admin-view.ts │ │ ├── d3/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── index.html │ │ ├── index.ts │ │ └── snippets/ │ │ ├── s1.ts │ │ ├── s2.ts │ │ └── s3.ts │ ├── route-actions/ │ │ ├── d1/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d2/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d3/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d4/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d5/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── index.html │ │ └── index.ts │ ├── route-parameters/ │ │ ├── d1/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d2/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-project-view.ts │ │ ├── d3/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d4/ │ │ │ ├── iframe.html │ │ │ └── script.ts │ │ ├── d5/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-page-number-view.ts │ │ ├── d6/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-hash-view.ts │ │ ├── index.html │ │ └── index.ts │ ├── tsconfig.json │ ├── types.t.ts │ ├── url-generation/ │ │ ├── d1/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-main-layout.ts │ │ ├── d2/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-main-layout.ts │ │ ├── d3/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-user-layout-d3.ts │ │ ├── d4/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-user-layout-d4.ts │ │ ├── d5/ │ │ │ ├── iframe.html │ │ │ ├── script.ts │ │ │ └── x-pages-menu.ts │ │ ├── index.html │ │ └── index.ts │ └── vite.config.ts ├── index.html ├── karma.config.cjs ├── package.json ├── polymer.json ├── scripts/ │ ├── build.ts │ ├── codeSnippet.ts │ ├── constructCss.ts │ ├── copy-dts.ts │ ├── loadRegisterJs.ts │ ├── register.js │ ├── resolveHTMLImports.ts │ └── types.d.ts ├── src/ │ ├── index.ts │ ├── mod.t.ts │ ├── resolver/ │ │ ├── LICENSE.txt │ │ ├── generateUrls.ts │ │ ├── matchPath.ts │ │ ├── matchRoute.ts │ │ ├── resolveRoute.ts │ │ ├── resolver.ts │ │ ├── types.t.ts │ │ └── utils.ts │ ├── router-config.ts │ ├── router-meta.ts │ ├── router.ts │ ├── transitions/ │ │ └── animate.ts │ ├── triggers/ │ │ ├── click.ts │ │ ├── navigation.ts │ │ └── popstate.ts │ ├── types.t.ts │ ├── utils.ts │ └── v1-compat.t.ts ├── test/ │ ├── resolver/ │ │ ├── LICENSE.txt │ │ ├── generateUrls.spec.ts │ │ ├── matchPath.spec.ts │ │ ├── matchRoute.spec.ts │ │ └── resolver.spec.ts │ ├── router/ │ │ ├── dynamic-redirect.spec.ts │ │ ├── lifecycle-events.spec.ts │ │ ├── parent-layout.spec.ts │ │ ├── router.spec.ts │ │ ├── test-utils.ts │ │ └── url-for.spec.ts │ ├── setup.ts │ ├── transitions/ │ │ └── animate.spec.ts │ ├── triggers/ │ │ ├── click.spec.ts │ │ ├── popstate.spec.ts │ │ └── setNavigationTriggers.spec.ts │ └── typescript/ │ └── compile_fixture.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsdoc.json ├── typedoc.json ├── vite.config.ts └── wct.conf.cjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.{java,xml}] indent_size = 4 [*.md] insert_final_newline = false trim_trailing_whitespace = false ================================================ FILE: .eslintignore ================================================ src/documentation/*.js ================================================ FILE: .eslintrc.json ================================================ { "extends": [ "vaadin/typescript-requiring-type-checking", "vaadin/imports-typescript", "vaadin/lit", "vaadin/prettier", "vaadin/testing" ], "parserOptions": { "project": "./tsconfig.json" }, "plugins": ["tsdoc"], "rules": { "@typescript-eslint/no-restricted-types": "off", "@typescript-eslint/no-invalid-void-type": "off", "@typescript-eslint/no-useless-template-literals": "off", "import/no-unassigned-import": "off", "max-params": "off", "sort-keys": "off", "tsdoc/syntax": "error", "import/prefer-default-export": "off" }, "ignorePatterns": ["*.cjs"] } ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: 'npm' directory: '/' schedule: interval: 'daily' versioning-strategy: increase ================================================ FILE: .github/workflows/docs.yml ================================================ name: Publish Docs on: push: branches: ['main'] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: 'pages' cancel-in-progress: false jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Pages uses: actions/configure-pages@v5 - name: Use NodeJS LTS uses: actions/setup-node@v4 with: node-version: lts/* - name: Install run: npm ci - name: Build Docs run: npm run docs - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: './.docs' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/validation.yml ================================================ name: Validation on: push: branches: - 'master' pull_request: permissions: contents: read jobs: test: name: Validation runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout Project Code uses: actions/checkout@v3 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: 'lts/*' - name: Install dependencies and build run: npm ci - name: Build TypeScript run: npm run build - name: Check TypeScript run: npm run typecheck - name: Test run: npm test ================================================ FILE: .gitignore ================================================ bower_components node_modules build dist coverage .idea .vscode .docs ================================================ FILE: .prettierrc.json ================================================ { "bracketSpacing": true, "printWidth": 120, "trailingComma": "all", "tabWidth": 2, "singleQuote": true } ================================================ FILE: .run/All tests.run.xml ================================================ ================================================ FILE: .run/Template Karma.run.xml ================================================ ================================================ FILE: .stylelintrc.json ================================================ { "rules": { "at-rule-name-case": "lower", "at-rule-name-space-after": "always-single-line", "at-rule-semicolon-newline-after": "always", "block-closing-brace-empty-line-before": "never", "block-closing-brace-newline-after": "always", "block-closing-brace-newline-before": "always-multi-line", "block-closing-brace-space-before": "always-single-line", "block-no-empty": true, "block-opening-brace-newline-after": "always-multi-line", "block-opening-brace-space-after": "always-single-line", "block-opening-brace-space-before": "always", "color-hex-length": "short", "color-no-invalid-hex": true, "comment-no-empty": true, "comment-whitespace-inside": "always", "custom-property-empty-line-before": "never", "declaration-bang-space-after": "never", "declaration-bang-space-before": "always", "declaration-block-no-duplicate-properties": [ true, { "ignore": ["consecutive-duplicates-with-different-values"] } ], "declaration-block-no-redundant-longhand-properties": true, "declaration-block-no-shorthand-property-overrides": true, "declaration-block-semicolon-newline-after": "always-multi-line", "declaration-block-semicolon-space-after": "always-single-line", "declaration-block-semicolon-space-before": "never", "declaration-block-single-line-max-declarations": 1, "declaration-block-trailing-semicolon": "always", "declaration-colon-newline-after": "always-multi-line", "declaration-colon-space-after": "always-single-line", "declaration-colon-space-before": "never", "font-family-no-duplicate-names": true, "function-calc-no-unspaced-operator": true, "function-comma-newline-after": "always-multi-line", "function-comma-space-after": "always-single-line", "function-comma-space-before": "never", "function-linear-gradient-no-nonstandard-direction": true, "function-max-empty-lines": 0, "function-name-case": "lower", "function-parentheses-newline-inside": "always-multi-line", "function-parentheses-space-inside": "never-single-line", "function-whitespace-after": "always", "indentation": null, "keyframe-declaration-no-important": true, "length-zero-no-unit": true, "max-empty-lines": 1, "media-feature-colon-space-after": "always", "media-feature-colon-space-before": "never", "media-feature-name-case": "lower", "media-feature-name-no-unknown": true, "media-feature-parentheses-space-inside": "never", "media-feature-range-operator-space-after": "always", "media-feature-range-operator-space-before": "always", "media-query-list-comma-newline-after": "always-multi-line", "media-query-list-comma-space-after": "always-single-line", "media-query-list-comma-space-before": "never", "no-eol-whitespace": true, "no-invalid-double-slash-comments": true, "number-no-trailing-zeros": true, "property-case": "lower", "property-no-unknown": true, "rule-empty-line-before": [ "always-multi-line", { "except": ["first-nested"], "ignore": ["after-comment"] } ], "selector-attribute-brackets-space-inside": "never", "selector-attribute-operator-space-after": "never", "selector-attribute-operator-space-before": "never", "selector-combinator-space-after": "always", "selector-combinator-space-before": "always", "selector-descendant-combinator-no-non-space": true, "selector-list-comma-newline-after": "always", "selector-list-comma-space-before": "never", "selector-max-empty-lines": 0, "selector-pseudo-class-case": "lower", "selector-pseudo-class-no-unknown": true, "selector-pseudo-class-parentheses-space-inside": "never", "selector-pseudo-element-case": "lower", "selector-pseudo-element-colon-notation": "double", "selector-pseudo-element-no-unknown": true, "selector-type-case": "lower", "shorthand-property-no-redundant-values": true, "string-no-newline": true, "unit-case": "lower", "unit-no-unknown": true, "value-list-comma-newline-after": "always-multi-line", "value-list-comma-space-after": "always-single-line", "value-list-comma-space-before": "never", "value-list-max-empty-lines": 0 } } ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2017 Vaadin Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ [![NPM version](https://img.shields.io/npm/v/@vaadin/router.svg)](https://www.npmjs.com/package/@vaadin/router) [![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/@vaadin/router.svg)](https://bundlephobia.com/result?p=@vaadin/router) # Vaadin Router is deprecated This library is no longer actively maintained. Vaadin uses React Router as a primary client-side routing tool. Consider [`@lit-labs/router`](https://www.npmjs.com/package/@lit-labs/router) as a more lightweight and modern alternative. Also, as the [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) API is universally available in modern browsers nowadays, we recommend using that for building customized client-side routing. --- # Vaadin Router [Live demo](https://vaadin.github.io/router/index.html) | [API documentation](https://vaadin.github.io/router/API/index.html) Vaadin Router is a small and powerful client-side router JS library. It uses the widely adopted express.js syntax for routes (`/users/:id`) to map URLs to Web Component views. All features one might expect from a modern router are supported: async route resolution, animated transitions, navigation guards, redirects, and more. It is framework-agnostic and works equally well with all Web Components regardless of how they are created (Polymer / SkateJS / Stencil / Angular / Vue / etc). Vaadin Router is a good fit for developers that do not want to go all-in with one framework, and prefer to have freedom in picking the components that work best for their specific needs. ``` npm install --save @vaadin/router ``` ```javascript import {Router} from '@vaadin/router'; const router = new Router(document.getElementById('outlet')); router.setRoutes([ {path: '/', component: 'x-home-view'}, {path: '/users', component: 'x-user-list'} ]); ``` ## Browser support A specific version of Vaadin Router supports the same browsers as the Vaadin platform major version which includes that version of Vaadin Router. See [Vaadin platform release notes](https://github.com/vaadin/platform/releases) for details on included Vaadin Router version and supported technologies. The Supported Technologies section is typically listed in the release notes of the first publicly available release of a Vaadin platform major version (for example [Vaadin 18.0.1](https://github.com/vaadin/platform/releases/tag/18.0.1) since 18.0.0 was skipped). ### Desktop browsers Evergreen versions of the following browsers - Chrome, Firefox, Firefox ESR, Safari and Edge (Chromium) ### Mobile browsers Built-in browsers in the following mobile operating systems: - Safari starting from iOS 13 (Safari 13 or newer) - Google Chrome evergreen on Android (requiring Android 4.4 or newer) ### Sauce Labs test status [![Sauce Test Status](https://saucelabs.com/browser-matrix/vaadin-router.svg)](https://saucelabs.com/u/vaadin-router) ### Big Thanks Cross-browser Testing Platform and Open Source <3 Provided by [Sauce Labs](https://saucelabs.com). ## Running demos and tests in the browser 1. Fork the `vaadin/router` GitHub repository and clone it locally. 1. Make sure you have [npm](https://www.npmjs.com/) installed. 1. In the router directory, run `npm install` to install dependencies. 1. Run `npm run build` to build the library. 1. Run `npm start`, and open [http://localhost:4173](http://127.0.0.1:8000/components/vaadin-router) in your browser to see the component live demos and API documentation. ## Running tests from the command line 1. In the router directory, run `npm test` ## Following the coding style We are using [ESLint](http://eslint.org/) for linting JavaScript code. You can check if your code is following our standards by running `npm run lint`, which will automatically lint all `.js` files as well as JavaScript snippets inside `.html` files. ## Contributing - Make sure your code is compliant with our code linters: `npm run lint` - Check that tests are passing: `npm test` - [Submit a pull request](https://www.digitalocean.com/community/tutorials/how-to-create-a-pull-request-on-github) with detailed title and description - Wait for response from one of Vaadin components team members ## License Apache License 2.0 Vaadin collects development time usage statistics to improve this product. For details and to opt-out, see https://github.com/vaadin/vaadin-usage-statistics. ================================================ FILE: analysis.json ================================================ { "schema_version": "1.0.0", "namespaces": [ { "name": "Router", "description": "", "summary": "", "sourceRange": { "file": "src/documentation/namespace.js", "start": { "line": 3, "column": 0 }, "end": { "line": 3, "column": 18 } }, "classes": [ { "description": "This is a type declaration. It exists for build-time type checking and\ndocumentation purposes. It should not be used in any source code, and it\ncertainly does not exist at the run time.\n\n`Location` describes the state of a router at a given point in time. It is\navailable for your application code in several ways:\n - as the `router.location` property\n - as the `location` property set by Vaadin Router on every view Web Component\n - as the `location` argument passed by Vaadin Router into view Web Component\n lifecycle callbacks\n - as the `event.detail.location` of the global Vaadin Router events", "summary": "Type declaration for the `router.location` property.", "path": "src/documentation/location.js", "properties": [ { "name": "baseUrl", "type": "string", "description": "The base URL used in the router. See [the `baseUrl` property\n](#/classes/Router#property-baseUrl) in the Router.", "privacy": "public", "sourceRange": { "start": { "line": 25, "column": 4 }, "end": { "line": 25, "column": 17 } }, "metadata": {} }, { "name": "pathname", "type": "!string", "description": "The pathname as entered in the browser address bar\n(e.g. `/users/42/messages/12/edit`). It always starts with a `/` (slash).", "privacy": "public", "sourceRange": { "start": { "line": 34, "column": 4 }, "end": { "line": 34, "column": 18 } }, "metadata": {} }, { "name": "search", "type": "!string", "description": "The query string portion of the current url.", "privacy": "public", "sourceRange": { "start": { "line": 42, "column": 4 }, "end": { "line": 42, "column": 16 } }, "metadata": {} }, { "name": "searchParams", "type": "URLSearchParams", "description": "The query search parameters of the current url.", "privacy": "public", "sourceRange": { "start": { "line": 50, "column": 4 }, "end": { "line": 50, "column": 22 } }, "metadata": {} }, { "name": "hash", "type": "!string", "description": "The fragment identifier (including hash character) for the current page.", "privacy": "public", "sourceRange": { "start": { "line": 58, "column": 4 }, "end": { "line": 58, "column": 14 } }, "metadata": {} }, { "name": "redirectFrom", "type": "?string", "description": "(optional) The original pathname string in case if this location is a\nresult of a redirect.\n\nE.g. with the routes config as below a navigation to `/u/12` produces a\nlocation with `pathname: '/user/12'` and `redirectFrom: '/u/12'`.\n\n```javascript\nsetRoutes([\n {path: '/u/:id', redirect: '/user/:id'},\n {path: '/user/:id', component: 'x-user-view'},\n]);\n```\n\nSee the **Redirects** section of the\n[live demos](#/classes/Router/demos/demo/index.html) for more\ndetails.", "privacy": "public", "sourceRange": { "start": { "line": 81, "column": 4 }, "end": { "line": 81, "column": 22 } }, "metadata": {} }, { "name": "route", "type": "?Route", "description": "(optional) The route object associated with `this` Web Component instance.\n\nThis property is defined in the `location` objects that are passed as\nparameters into Web Component lifecycle callbacks, and the `location`\nproperty set by Vaadin Router on the Web Components.\n\nThis property is undefined in the `location` objects that are available\nas `router.location`, and in the `location` that is included into the\nglobal router event details.", "privacy": "public", "sourceRange": { "start": { "line": 97, "column": 4 }, "end": { "line": 97, "column": 15 } }, "metadata": {} }, { "name": "routes", "type": "!Array.", "description": "A list of route objects that match the current pathname. This list has\none element for each route that defines a parent layout, and then the\nelement for the route that defines the view.\n\nSee the **Getting Started** section of the\n[live demos](#/classes/Router/demos/demo/index.html) for more\ndetails on child routes and nested layouts.", "privacy": "public", "sourceRange": { "start": { "line": 111, "column": 4 }, "end": { "line": 111, "column": 16 } }, "metadata": {} }, { "name": "params", "type": "!IndexedParams", "description": "A bag of key-value pairs with parameters for the current location. Named\nparameters are available by name, unnamed ones - by index (e.g. for the\n`/users/:id` route the `:id` parameter is available as `location.params.id`).\n\nSee the **Route Parameters** section of the\n[live demos](#/classes/Router/demos/demo/index.html) for more\ndetails.", "privacy": "public", "sourceRange": { "start": { "line": 125, "column": 4 }, "end": { "line": 125, "column": 16 } }, "metadata": {} } ], "methods": [ { "name": "getUrl", "description": "Returns a URL corresponding to the route path and the parameters of this\nlocation. When the parameters object is given in the arguments,\nthe argument parameters override the location ones.", "privacy": "public", "sourceRange": { "start": { "line": 140, "column": 2 }, "end": { "line": 140, "column": 20 } }, "metadata": {}, "params": [ { "name": "params", "type": "Params=", "description": "optional object with parameters to override.\nNamed parameters are passed by name (`params[name] = value`), unnamed\nparameters are passed by index (`params[index] = value`)." } ], "return": { "type": "string" } } ], "staticMethods": [], "demos": [], "metadata": {}, "sourceRange": { "start": { "line": 16, "column": 7 }, "end": { "line": 141, "column": 1 } }, "privacy": "public", "name": "Router.Location" } ] } ], "classes": [ { "description": "", "summary": "", "path": "dist/vaadin-router.js", "properties": [], "methods": [], "staticMethods": [], "demos": [], "metadata": {}, "sourceRange": { "start": { "line": 89, "column": 28 }, "end": { "line": 89, "column": 51 } }, "privacy": "public", "name": "NotFoundResult" }, { "description": "", "summary": "", "path": "dist/vaadin-router.js", "properties": [ { "name": "__effectiveBaseUrl", "type": "?", "description": "If the baseUrl property is set, transforms the baseUrl and returns the full\nactual `base` string for using in the `new URL(path, base);` and for\nprepernding the paths with. The returned base ends with a trailing slash.\n\nOtherwise, returns empty string.\n ", "privacy": "private", "sourceRange": { "start": { "line": 1038, "column": 2 }, "end": { "line": 1045, "column": 3 } }, "metadata": {} } ], "methods": [ { "name": "getRoutes", "description": "Returns the current list of routes (as a shallow copy). Adding / removing\nroutes to / from the returned array does not affect the routing config,\nbut modifying the route objects does.", "privacy": "public", "sourceRange": { "start": { "line": 895, "column": 2 }, "end": { "line": 897, "column": 3 } }, "metadata": {}, "params": [], "return": { "type": "!Array." } }, { "name": "setRoutes", "description": "Sets the routing config (replacing the existing one).", "privacy": "public", "sourceRange": { "start": { "line": 905, "column": 2 }, "end": { "line": 909, "column": 3 } }, "metadata": {}, "params": [ { "name": "routes", "type": "(!Array. | !Router.Route)", "description": "a single route or an array of those\n (the array is shallow copied)" } ], "return": { "type": "void" } }, { "name": "addRoutes", "description": "Appends one or several routes to the routing config and returns the\neffective routing config after the operation.", "privacy": "protected", "sourceRange": { "start": { "line": 920, "column": 2 }, "end": { "line": 924, "column": 3 } }, "metadata": {}, "params": [ { "name": "routes", "type": "(!Array. | !Router.Route)", "description": "a single route or an array of those\n (the array is shallow copied)" } ], "return": { "type": "!Array." } }, { "name": "removeRoutes", "description": "Removes all existing routes from the routing config.", "privacy": "public", "sourceRange": { "start": { "line": 929, "column": 2 }, "end": { "line": 931, "column": 3 } }, "metadata": {}, "params": [], "return": { "type": "void" } }, { "name": "resolve", "description": "Asynchronously resolves the given pathname, i.e. finds all routes matching\nthe pathname and tries resolving them one after another in the order they\nare listed in the routes config until the first non-null result.\n\nReturns a promise that is fulfilled with the return value of an object that consists of the first\nroute handler result that returns something other than `null` or `undefined` and context used to get this result.\n\nIf no route handlers return a non-null result, or if no route matches the\ngiven pathname the returned promise is rejected with a 'page not found'\n`Error`.", "privacy": "public", "sourceRange": { "start": { "line": 950, "column": 2 }, "end": { "line": 1022, "column": 3 } }, "metadata": {}, "params": [ { "name": "pathnameOrContext", "type": "(!string | !{pathname: !string})", "description": "the pathname to\n resolve or a context object with a `pathname` property and other\n properties to pass to the route resolver functions." } ], "return": { "type": "!Promise." } }, { "name": "__normalizePathname", "description": "If the baseUrl is set, matches the pathname with the router’s baseUrl,\nand returns the local pathname with the baseUrl stripped out.\n\nIf the pathname does not match the baseUrl, returns undefined.\n\nIf the `baseUrl` is not set, returns the unmodified pathname argument.", "privacy": "private", "sourceRange": { "start": { "line": 1055, "column": 2 }, "end": { "line": 1070, "column": 3 } }, "metadata": {}, "params": [ { "name": "pathname" } ] } ], "staticMethods": [ { "name": "__createUrl", "description": "URL constructor polyfill hook. Creates and returns an URL instance.", "privacy": "private", "sourceRange": { "start": { "line": 1027, "column": 2 }, "end": { "line": 1029, "column": 3 } }, "metadata": {}, "params": [ { "name": "args", "rest": true } ] } ], "demos": [], "metadata": {}, "sourceRange": { "start": { "line": 874, "column": 0 }, "end": { "line": 1071, "column": 1 } }, "privacy": "public", "name": "Resolver" }, { "description": "A simple client-side router for single-page applications. It uses\nexpress-style middleware and has a first-class support for Web Components and\nlazy-loading. Works great in Polymer and non-Polymer apps.\n\nUse `new Router(outlet, options)` to create a new Router instance.\n\n* The `outlet` parameter is a reference to the DOM node to render\n the content into.\n\n* The `options` parameter is an optional object with options. The following\n keys are supported:\n * `baseUrl` — the initial value for [\n the `baseUrl` property\n ](#/classes/Router#property-baseUrl)\n\nThe Router instance is automatically subscribed to navigation events\non `window`.\n\nSee [Live Examples](#/classes/Router/demos/demo/index.html) for the detailed usage demo and code snippets.\n\nSee also detailed API docs for the following methods, for the advanced usage:\n\n* [setOutlet](#/classes/Router#method-setOutlet) – should be used to configure the outlet.\n* [setTriggers](#/classes/Router#method-setTriggers) – should be used to configure the navigation events.\n* [setRoutes](#/classes/Router#method-setRoutes) – should be used to configure the routes.\n\nOnly `setRoutes` has to be called manually, others are automatically invoked when creating a new instance.", "summary": "JavaScript class that renders different DOM content depending on\n a given path. It can re-render when triggered or automatically on\n 'popstate' and / or 'click' events.", "path": "dist/vaadin-router.js", "properties": [ { "name": "__effectiveBaseUrl", "type": "?", "description": "If the baseUrl property is set, transforms the baseUrl and returns the full\nactual `base` string for using in the `new URL(path, base);` and for\nprepernding the paths with. The returned base ends with a trailing slash.\n\nOtherwise, returns empty string.\n ", "privacy": "private", "sourceRange": { "start": { "line": 1038, "column": 2 }, "end": { "line": 1045, "column": 3 } }, "metadata": {}, "inheritedFrom": "Resolver" }, { "name": "baseUrl", "type": "string", "description": "The base URL for all routes in the router instance. By default,\nif the base element exists in the ``, vaadin-router\ntakes the `` attribute value, resolved against the current\n`document.URL`.", "privacy": "public", "sourceRange": { "start": { "line": 1410, "column": 4 }, "end": { "line": 1410, "column": 17 } }, "metadata": {} }, { "name": "ready", "type": "!Promise.", "description": "A promise that is settled after the current render cycle completes. If\nthere is no render cycle in progress the promise is immediately settled\nwith the last render cycle result.", "privacy": "public", "sourceRange": { "start": { "line": 1420, "column": 4 }, "end": { "line": 1420, "column": 15 } }, "metadata": {} }, { "name": "location", "type": "!RouterLocation", "description": "Contains read-only information about the current router location:\npathname, active routes, parameters. See the\n[Location type declaration](#/classes/RouterLocation)\nfor more details.", "privacy": "public", "sourceRange": { "start": { "line": 1432, "column": 4 }, "end": { "line": 1432, "column": 18 } }, "metadata": {} } ], "methods": [ { "name": "getRoutes", "description": "Returns the current list of routes (as a shallow copy). Adding / removing\nroutes to / from the returned array does not affect the routing config,\nbut modifying the route objects does.", "privacy": "public", "sourceRange": { "start": { "line": 895, "column": 2 }, "end": { "line": 897, "column": 3 } }, "metadata": {}, "params": [], "return": { "type": "!Array." }, "inheritedFrom": "Resolver" }, { "name": "setRoutes", "description": "Sets the routing config (replacing the existing one) and triggers a\nnavigation event so that the router outlet is refreshed according to the\ncurrent `window.location` and the new routing config.\n\nEach route object may have the following properties, listed here in the processing order:\n* `path` – the route path (relative to the parent route if any) in the\n[express.js syntax](https://expressjs.com/en/guide/routing.html#route-paths\").\n\n* `children` – an array of nested routes or a function that provides this\narray at the render time. The function can be synchronous or asynchronous:\nin the latter case the render is delayed until the returned promise is\nresolved. The `children` function is executed every time when this route is\nbeing rendered. This allows for dynamic route structures (e.g. backend-defined),\nbut it might have a performance impact as well. In order to avoid calling\nthe function on subsequent renders, you can override the `children` property\nof the route object and save the calculated array there\n(via `context.route.children = [ route1, route2, ...];`).\nParent routes are fully resolved before resolving the children. Children\n'path' values are relative to the parent ones.\n\n* `action` – the action that is executed before the route is resolved.\nThe value for this property should be a function, accepting `context`\nand `commands` parameters described below. If present, this function is\nalways invoked first, disregarding of the other properties' presence.\nThe action can return a result directly or within a `Promise`, which\nresolves to the result. If the action result is an `HTMLElement` instance,\na `commands.component(name)` result, a `commands.redirect(path)` result,\nor a `context.next()` result, the current route resolution is finished,\nand other route config properties are ignored.\nSee also **Route Actions** section in [Live Examples](#/classes/Router/demos/demo/index.html).\n\n* `redirect` – other route's path to redirect to. Passes all route parameters to the redirect target.\nThe target route should also be defined.\nSee also **Redirects** section in [Live Examples](#/classes/Router/demos/demo/index.html).\n\n* `component` – the tag name of the Web Component to resolve the route to.\nThe property is ignored when either an `action` returns the result or `redirect` property is present.\nIf route contains the `component` property (or an action that return a component)\nand its child route also contains the `component` property, child route's component\nwill be rendered as a light dom child of a parent component.\n\n* `name` – the string name of the route to use in the\n[`router.urlForName(name, params)`](#/classes/Router#method-urlForName)\nnavigation helper method.\n\nFor any route function (`action`, `children`) defined, the corresponding `route` object is available inside the callback\nthrough the `this` reference. If you need to access it, make sure you define the callback as a non-arrow function\nbecause arrow functions do not have their own `this` reference.\n\n`context` object that is passed to `action` function holds the following properties:\n* `context.pathname` – string with the pathname being resolved\n\n* `context.search` – search query string\n\n* `context.hash` – hash string\n\n* `context.params` – object with route parameters\n\n* `context.route` – object that holds the route that is currently being rendered.\n\n* `context.next()` – function for asynchronously getting the next route\ncontents from the resolution chain (if any)\n\n`commands` object that is passed to `action` function has\nthe following methods:\n\n* `commands.redirect(path)` – function that creates a redirect data\nfor the path specified.\n\n* `commands.component(component)` – function that creates a new HTMLElement\nwith current context. Note: the component created by this function is reused if visiting the same path twice in row.", "privacy": "public", "sourceRange": { "start": { "line": 1609, "column": 2 }, "end": { "line": 1617, "column": 3 } }, "metadata": {}, "params": [ { "name": "routes", "type": "(!Array. | !Route)", "description": "a single route or an array of those" }, { "name": "skipRender", "type": "?boolean", "defaultValue": "false", "description": "configure the router but skip rendering the\n route corresponding to the current `window.location` values" } ], "return": { "type": "!Promise." } }, { "name": "addRoutes", "description": "Appends one or several routes to the routing config and returns the\neffective routing config after the operation.", "privacy": "protected", "sourceRange": { "start": { "line": 920, "column": 2 }, "end": { "line": 924, "column": 3 } }, "metadata": {}, "params": [ { "name": "routes", "type": "(!Array. | !Router.Route)", "description": "a single route or an array of those\n (the array is shallow copied)" } ], "return": { "type": "!Array." }, "inheritedFrom": "Resolver" }, { "name": "removeRoutes", "description": "Removes all existing routes from the routing config.", "privacy": "public", "sourceRange": { "start": { "line": 929, "column": 2 }, "end": { "line": 931, "column": 3 } }, "metadata": {}, "params": [], "return": { "type": "void" }, "inheritedFrom": "Resolver" }, { "name": "resolve", "description": "Asynchronously resolves the given pathname, i.e. finds all routes matching\nthe pathname and tries resolving them one after another in the order they\nare listed in the routes config until the first non-null result.\n\nReturns a promise that is fulfilled with the return value of an object that consists of the first\nroute handler result that returns something other than `null` or `undefined` and context used to get this result.\n\nIf no route handlers return a non-null result, or if no route matches the\ngiven pathname the returned promise is rejected with a 'page not found'\n`Error`.", "privacy": "public", "sourceRange": { "start": { "line": 950, "column": 2 }, "end": { "line": 1022, "column": 3 } }, "metadata": {}, "params": [ { "name": "pathnameOrContext", "type": "(!string | !{pathname: !string})", "description": "the pathname to\n resolve or a context object with a `pathname` property and other\n properties to pass to the route resolver functions." } ], "return": { "type": "!Promise." }, "inheritedFrom": "Resolver" }, { "name": "__normalizePathname", "description": "If the baseUrl is set, matches the pathname with the router’s baseUrl,\nand returns the local pathname with the baseUrl stripped out.\n\nIf the pathname does not match the baseUrl, returns undefined.\n\nIf the `baseUrl` is not set, returns the unmodified pathname argument.", "privacy": "private", "sourceRange": { "start": { "line": 1055, "column": 2 }, "end": { "line": 1070, "column": 3 } }, "metadata": {}, "params": [ { "name": "pathname" } ], "inheritedFrom": "Resolver" }, { "name": "__resolveRoute", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1444, "column": 2 }, "end": { "line": 1501, "column": 3 } }, "metadata": {}, "params": [ { "name": "context" } ] }, { "name": "setOutlet", "description": "Sets the router outlet (the DOM node where the content for the current\nroute is inserted). Any content pre-existing in the router outlet is\nremoved at the end of each render pass.\n\nNOTE: this method is automatically invoked first time when creating a new Router instance.", "privacy": "public", "sourceRange": { "start": { "line": 1513, "column": 2 }, "end": { "line": 1518, "column": 3 } }, "metadata": {}, "params": [ { "name": "outlet", "type": "?Node", "description": "the DOM node where the content for the current route\n is inserted." } ], "return": { "type": "void" } }, { "name": "getOutlet", "description": "Returns the current router outlet. The initial value is `undefined`.", "privacy": "public", "sourceRange": { "start": { "line": 1525, "column": 2 }, "end": { "line": 1527, "column": 3 } }, "metadata": {}, "params": [], "return": { "type": "?Node", "desc": "the current router outlet (or `undefined`)" } }, { "name": "render", "description": "Asynchronously resolves the given pathname and renders the resolved route\ncomponent into the router outlet. If no router outlet is set at the time of\ncalling this method, or at the time when the route resolution is completed,\na `TypeError` is thrown.\n\nReturns a promise that is fulfilled with the router outlet DOM Node after\nthe route component is created and inserted into the router outlet, or\nrejected if no route matches the given path.\n\nIf another render pass is started before the previous one is completed, the\nresult of the previous render pass is ignored.", "privacy": "public", "sourceRange": { "start": { "line": 1640, "column": 2 }, "end": { "line": 1724, "column": 3 } }, "metadata": {}, "params": [ { "name": "pathnameOrContext", "type": "(!string | !{pathname: !string, search: ?string, hash: ?string})", "description": "the pathname to render or a context object with a `pathname` property,\n optional `search` and `hash` properties, and other properties\n to pass to the resolver." }, { "name": "shouldUpdateHistory", "type": "boolean=", "description": "update browser history with the rendered location" } ], "return": { "type": "!Promise." } }, { "name": "__fullyResolveChain", "description": "and 'redirect' callback results.", "privacy": "private", "sourceRange": { "start": { "line": 1737, "column": 2 }, "end": { "line": 1785, "column": 3 } }, "metadata": {}, "params": [ { "name": "topOfTheChainContextBeforeRedirects" }, { "name": "contextBeforeRedirects", "defaultValue": "topOfTheChainContextBeforeRedirects" } ] }, { "name": "__findComponentContextAfterAllRedirects", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1787, "column": 2 }, "end": { "line": 1807, "column": 3 } }, "metadata": {}, "params": [ { "name": "context" } ] }, { "name": "__amendWithOnBeforeCallbacks", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1809, "column": 2 }, "end": { "line": 1816, "column": 3 } }, "metadata": {}, "params": [ { "name": "contextWithFullChain" } ] }, { "name": "__runOnBeforeCallbacks", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1818, "column": 2 }, "end": { "line": 1890, "column": 3 } }, "metadata": {}, "params": [ { "name": "newContext" } ] }, { "name": "__runOnBeforeLeaveCallbacks", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1892, "column": 2 }, "end": { "line": 1904, "column": 3 } }, "metadata": {}, "params": [ { "name": "callbacks" }, { "name": "newContext" }, { "name": "commands" }, { "name": "chainElement" } ] }, { "name": "__runOnBeforeEnterCallbacks", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1906, "column": 2 }, "end": { "line": 1914, "column": 3 } }, "metadata": {}, "params": [ { "name": "callbacks" }, { "name": "newContext" }, { "name": "commands" }, { "name": "chainElement" } ] }, { "name": "__isReusableElement", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1916, "column": 2 }, "end": { "line": 1923, "column": 3 } }, "metadata": {}, "params": [ { "name": "element" }, { "name": "otherElement" } ] }, { "name": "__isLatestRender", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1925, "column": 2 }, "end": { "line": 1927, "column": 3 } }, "metadata": {}, "params": [ { "name": "context" } ] }, { "name": "__redirect", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1929, "column": 2 }, "end": { "line": 1943, "column": 3 } }, "metadata": {}, "params": [ { "name": "redirectData" }, { "name": "counter" }, { "name": "renderId" } ] }, { "name": "__ensureOutlet", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1945, "column": 2 }, "end": { "line": 1949, "column": 3 } }, "metadata": {}, "params": [ { "name": "outlet", "defaultValue": "this.__outlet" } ], "return": { "type": "void" } }, { "name": "__updateBrowserHistory", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1951, "column": 2 }, "end": { "line": 1960, "column": 3 } }, "metadata": {}, "params": [ { "name": "{\n pathname,\n search = '',\n hash = ''\n}" }, { "name": "replace" } ], "return": { "type": "void" } }, { "name": "__copyUnchangedElements", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1962, "column": 2 }, "end": { "line": 1978, "column": 3 } }, "metadata": {}, "params": [ { "name": "context" }, { "name": "previousContext" } ] }, { "name": "__addAppearingContent", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 1980, "column": 2 }, "end": { "line": 2018, "column": 3 } }, "metadata": {}, "params": [ { "name": "context" }, { "name": "previousContext" } ], "return": { "type": "void" } }, { "name": "__removeDisappearingContent", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 2020, "column": 2 }, "end": { "line": 2026, "column": 3 } }, "metadata": {}, "params": [], "return": { "type": "void" } }, { "name": "__removeAppearingContent", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 2028, "column": 2 }, "end": { "line": 2034, "column": 3 } }, "metadata": {}, "params": [], "return": { "type": "void" } }, { "name": "__runOnAfterLeaveCallbacks", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 2036, "column": 2 }, "end": { "line": 2062, "column": 3 } }, "metadata": {}, "params": [ { "name": "currentContext" }, { "name": "targetContext" } ], "return": { "type": "void" } }, { "name": "__runOnAfterEnterCallbacks", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 2064, "column": 2 }, "end": { "line": 2077, "column": 3 } }, "metadata": {}, "params": [ { "name": "currentContext" } ], "return": { "type": "void" } }, { "name": "__animateIfNeeded", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 2079, "column": 2 }, "end": { "line": 2101, "column": 3 } }, "metadata": {}, "params": [ { "name": "context" } ] }, { "name": "subscribe", "description": "Subscribes this instance to navigation events on the `window`.\n\nNOTE: beware of resource leaks. For as long as a router instance is\nsubscribed to navigation events, it won't be garbage collected.", "privacy": "public", "sourceRange": { "start": { "line": 2109, "column": 2 }, "end": { "line": 2111, "column": 3 } }, "metadata": {}, "params": [], "return": { "type": "void" } }, { "name": "unsubscribe", "description": "Removes the subscription to navigation events created in the `subscribe()`\nmethod.", "privacy": "public", "sourceRange": { "start": { "line": 2117, "column": 2 }, "end": { "line": 2119, "column": 3 } }, "metadata": {}, "params": [], "return": { "type": "void" } }, { "name": "__onNavigationEvent", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 2121, "column": 2 }, "end": { "line": 2129, "column": 3 } }, "metadata": {}, "params": [ { "name": "event" } ], "return": { "type": "void" } }, { "name": "urlForName", "description": "Generates a URL for the route with the given name, optionally performing\nsubstitution of parameters.\n\nThe route is searched in all the Router instances subscribed to\nnavigation events.\n\n**Note:** For child route names, only array children are considered.\nIt is not possible to generate URLs using a name for routes set with\na children function.", "privacy": "public", "sourceRange": { "start": { "line": 2170, "column": 2 }, "end": { "line": 2178, "column": 3 } }, "metadata": {}, "params": [ { "name": "name", "type": "!string", "description": "the route name or the route’s `component` name." }, { "name": "params", "type": "Params=", "description": "Optional object with route path parameters.\nNamed parameters are passed by name (`params[name] = value`), unnamed\nparameters are passed by index (`params[index] = value`)." } ], "return": { "type": "string" } }, { "name": "urlForPath", "description": "Generates a URL for the given route path, optionally performing\nsubstitution of parameters.", "privacy": "public", "sourceRange": { "start": { "line": 2191, "column": 2 }, "end": { "line": 2196, "column": 3 } }, "metadata": {}, "params": [ { "name": "path", "type": "!string", "description": "string route path declared in [express.js syntax](https://expressjs.com/en/guide/routing.html#route-paths\")." }, { "name": "params", "type": "Params=", "description": "Optional object with route path parameters.\nNamed parameters are passed by name (`params[name] = value`), unnamed\nparameters are passed by index (`params[index] = value`)." } ], "return": { "type": "string" } } ], "staticMethods": [ { "name": "__createUrl", "description": "URL constructor polyfill hook. Creates and returns an URL instance.", "privacy": "private", "sourceRange": { "start": { "line": 1027, "column": 2 }, "end": { "line": 1029, "column": 3 } }, "metadata": {}, "params": [ { "name": "args", "rest": true } ], "inheritedFrom": "Resolver" }, { "name": "setTriggers", "description": "Configures what triggers Router navigation events:\n - `POPSTATE`: popstate events on the current `window`\n - `CLICK`: click events on `` links leading to the current page\n\nThis method is invoked with the pre-configured values when creating a new Router instance.\nBy default, both `POPSTATE` and `CLICK` are enabled. This setup is expected to cover most of the use cases.\n\nSee the `router-config.js` for the default navigation triggers config. Based on it, you can\ncreate the own one and only import the triggers you need, instead of pulling in all the code,\ne.g. if you want to handle `click` differently.\n\nSee also **Navigation Triggers** section in [Live Examples](#/classes/Router/demos/demo/index.html).", "privacy": "public", "sourceRange": { "start": { "line": 2147, "column": 2 }, "end": { "line": 2149, "column": 3 } }, "metadata": {}, "params": [ { "name": "triggers", "type": "...NavigationTrigger", "rest": true } ], "return": { "type": "void" } }, { "name": "go", "description": "Triggers navigation to a new path. Returns a boolean without waiting until\nthe navigation is complete. Returns `true` if at least one `Router`\nhas handled the navigation (was subscribed and had `baseUrl` matching\nthe `path` argument), otherwise returns `false`.", "privacy": "public", "sourceRange": { "start": { "line": 2209, "column": 2 }, "end": { "line": 2214, "column": 3 } }, "metadata": {}, "params": [ { "name": "path", "type": "(!string | !{pathname: !string, search: (string | undefined), hash: (string | undefined)})", "description": "a new in-app path string, or an URL-like object with `pathname`\n string property, and optional `search` and `hash` string properties." } ], "return": { "type": "boolean" } }, { "name": "__removeDomNodes", "description": "", "privacy": "private", "sourceRange": { "start": { "line": 2216, "column": 2 }, "end": { "line": 2225, "column": 3 } }, "metadata": {}, "params": [ { "name": "nodes" } ], "return": { "type": "void" } } ], "demos": [ { "url": "demo/index.html", "description": "" }, { "url": "demo/index.html", "description": "" } ], "metadata": {}, "sourceRange": { "start": { "line": 1374, "column": 0 }, "end": { "line": 2226, "column": 1 } }, "privacy": "public", "superclass": "Resolver", "name": "Router" }, { "description": "This interface describes the lifecycle callbacks supported by Vaadin Router\non view Web Components. It exists only for documentation purposes, i.e.\nyou _do not need_ to extend it in your code—defining a method with a\nmatching name is enough (this class does not exist at the run time).\n\nIf any of the methods described below are defined in a view Web Component,\nVaadin Router calls them at the corresponding points of the view\nlifecycle. Each method can either be synchronous or asynchronous (i.e. return\na Promise). In the latter case Vaadin Router waits until the promise is\nresolved and continues the navigation after that.\n\nCheck the [documentation on the `Router` class](#/classes/Router)\nto learn more.\n\nLifecycle callbacks are executed after the new path is resolved and after all\n`action` callbacks of the routes in the new path are executed.\n\nExample:\n\nFor the following routes definition,\n```\n// router and action declarations are omitted for brevity\nrouter.setRoutes([\n {path: '/a', action: actionA, children: [\n {path: '/b', action: actionB, component: 'component-b'},\n {path: '/c', action: actionC, component: 'component-c'}\n ]}\n]);\n```\nif the router first navigates to `/a/b` path and there was no view rendered\nbefore, the following events happen:\n* actionA\n* actionB\n* onBeforeEnterB (if defined in component-b)\n* outlet contents updated with component-b\n* onAfterEnterB (if defined in component-b)\n\nthen, if the router navigates to `/a/c`, the following events take place:\n* actionA\n* actionC\n* onBeforeLeaveB (if defined in component-b)\n* onBeforeEnterC (if defined in component-c)\n* onAfterLeaveB (if defined in component-b)\n* outlet contents updated with component-c\n* onAfterEnterC (if defined in component-c)\n\nIf a `Promise` is returned by any of the callbacks, it is resolved before\nproceeding further.\n\nAny of the `onBefore...` callbacks have a possibility to prevent\nthe navigation and fall back to the previous navigation result. If there is\nno result and this is the first resolution, an exception is thrown.\n\n`onAfter...` callbacks are considered as non-preventable, and their return\nvalue is ignored.\n\nOther examples can be found in the\n[live demos](#/classes/Router/demos/demo/index.html) and tests.", "summary": "", "path": "src/documentation/web-component-interface.js", "properties": [], "methods": [ { "name": "onBeforeLeave", "description": "Method that gets executed when user navigates away from the component\nthat had defined the method. The user can prevent the navigation\nby returning `commands.prevent()` from the method or same value wrapped\nin `Promise`. This effectively means that the corresponding component\nshould be resolved by the router before the method can be executed.\nIf the router navigates to the same path twice in a row, and this results\nin rendering the same component name (if the component is created\nusing `component` property in the route object) or the same component instance\n(if the component is created and returned inside `action` property of the route object),\nin the second time the method is not called. In case of navigating to a different path\nbut within the same route object, e.g. the path has parameter or wildcard,\nand this results in rendering the same component instance, the method is called if available.\nThe WebComponent instance on which the callback has been invoked is available inside the callback through\nthe `this` reference.\n\nReturn values:\n\n* if the `commands.prevent()` result is returned (immediately or\nas a Promise), the navigation is aborted and the outlet contents\nis not updated.\n* any other return value is ignored and Vaadin Router proceeds with\nthe navigation.\n\nArguments:", "privacy": "public", "sourceRange": { "start": { "line": 97, "column": 2 }, "end": { "line": 102, "column": 3 } }, "metadata": {}, "params": [ { "name": "location", "description": "the `RouterLocation` object" }, { "name": "commands", "description": "the commands object with the following methods:\n\n| Property | Description\n| -------------------|-------------\n| `commands.prevent()` | function that creates a special object that can be returned to abort the current navigation and fall back to the last one. If there is no existing one, an exception is thrown." }, { "name": "router", "description": "the `Router` instance" } ] }, { "name": "onBeforeEnter", "description": "Method that gets executed before the outlet contents is updated with\nthe new element. The user can prevent the navigation by returning\n`commands.prevent()` from the method or same value wrapped in `Promise`.\nIf the router navigates to the same path twice in a row, and this results\nin rendering the same component name (if the component is created\nusing `component` property in the route object) or the same component instance\n(if the component is created and returned inside `action` property of the route object),\nin the second time the method is not called. In case of navigating to a different path\nbut within the same route object, e.g. the path has parameter or wildcard,\nand this results in rendering the same component instance, the method is called if available.\nThe WebComponent instance on which the callback has been invoked is available inside the callback through\nthe `this` reference.\n\nReturn values:\n\n* if the `commands.prevent()` result is returned (immediately or\nas a Promise), the navigation is aborted and the outlet contents\nis not updated.\n* if the `commands.redirect(path)` result is returned (immediately or\nas a Promise), Vaadin Router ends navigation to the current path, and\nstarts a new navigation cycle to the new path.\n* any other return value is ignored and Vaadin Router proceeds with\nthe navigation.\n\nArguments:", "privacy": "public", "sourceRange": { "start": { "line": 141, "column": 2 }, "end": { "line": 146, "column": 3 } }, "metadata": {}, "params": [ { "name": "location", "description": "the `RouterLocation` object" }, { "name": "commands", "description": "the commands object with the following methods:\n\n| Property | Description\n| -------------------------|-------------\n| `commands.redirect(path)` | function that creates a redirect data for the path specified, to use as a return value from the callback.\n| `commands.prevent()` | function that creates a special object that can be returned to abort the current navigation and fall back to the last one. If there is no existing one, an exception is thrown." }, { "name": "router", "description": "the `Router` instance" } ] }, { "name": "onAfterLeave", "description": "Method that gets executed when user navigates away from the component that\nhad defined the method, just before the element is to be removed\nfrom the DOM. The difference between this method and `onBeforeLeave`\nis that when this method is executed, there is no way to abort\nthe navigation. This effectively means that the corresponding component\nshould be resolved by the router before the method can be executed.\nIf the router navigates to the same path twice in a row, and this results\nin rendering the same component name (if the component is created\nusing `component` property in the route object) or the same component instance\n(if the component is created and returned inside `action` property of the route object),\nin the second time the method is not called. The WebComponent instance on which the callback\nhas been invoked is available inside the callback through\nthe `this` reference.\n\nReturn values: any return value is ignored and Vaadin Router proceeds with the navigation.\n\nArguments:", "privacy": "public", "sourceRange": { "start": { "line": 171, "column": 2 }, "end": { "line": 174, "column": 3 } }, "metadata": {}, "params": [ { "name": "location", "description": "the `RouterLocation` object" }, { "name": "commands", "description": "empty object" }, { "name": "router", "description": "the `Router` instance" } ], "return": { "type": "void" } }, { "name": "onAfterEnter", "description": "Method that gets executed after the outlet contents is updated with the new\nelement. If the router navigates to the same path twice in a row, and this results\nin rendering the same component name (if the component is created\nusing `component` property in the route object) or the same component instance\n(if the component is created and returned inside `action` property of the route object),\nin the second time the method is not called. The WebComponent instance on which the callback\nhas been invoked is available inside the callback through\nthe `this` reference.\n\nThis callback is called asynchronously after the native\n[`connectedCallback()`](https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-reactions)\ndefined by the Custom Elements spec.\n\nReturn values: any return value is ignored and Vaadin Router proceeds with the navigation.\n\nArguments:", "privacy": "public", "sourceRange": { "start": { "line": 198, "column": 2 }, "end": { "line": 201, "column": 3 } }, "metadata": {}, "params": [ { "name": "location", "description": "the `RouterLocation` object" }, { "name": "commands", "description": "empty object" }, { "name": "router", "description": "the `Router` instance" } ], "return": { "type": "void" } } ], "staticMethods": [], "demos": [], "metadata": {}, "sourceRange": { "start": { "line": 61, "column": 7 }, "end": { "line": 202, "column": 1 } }, "privacy": "public", "name": "WebComponentInterface" } ] } ================================================ FILE: demo/.eslintrc.json ================================================ { "extends": ["../.eslintrc.json"], "root": true, "parserOptions": { "project": "./tsconfig.json" }, "rules": { "@typescript-eslint/unbound-method": "off" } } ================================================ FILE: demo/@debug/index.html ================================================ Debug ================================================ FILE: demo/@debug/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; // An URL of the iframe to debug // eslint-disable-next-line import/order import url1 from '../url-generation/d4/iframe.html?url'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-debug': DemoDebug; } } @customElement('vaadin-demo-debug') export default class DemoDebug extends LitElement { override render(): TemplateResult { return html``; } } ================================================ FILE: demo/@helpers/common.css ================================================ :host { display: block; } ================================================ FILE: demo/@helpers/common.ts ================================================ import '@vaadin/vaadin-lumo-styles/badge-global.js'; import '@vaadin/vaadin-lumo-styles/color-global.js'; import '@vaadin/vaadin-lumo-styles/typography-global.js'; ================================================ FILE: demo/@helpers/iframe.script.ts ================================================ import './common.css'; import './common.js'; import { Router } from '@vaadin/router'; history.replaceState(null, '', '/'); type MessageData = Readonly<{ url: string }>; type ParentData = { source: MessageEventSource | null; origin: string; }; let parentData: ParentData | undefined; function updateParentUrl() { if (parentData?.source) { parentData.source.postMessage({ url: location.href }, { targetOrigin: location.origin }); } } addEventListener('message', ({ data, origin, source }: MessageEvent) => { if (data != null) { Router.go(new URL(data.url, location.origin).href); } else { parentData = { source, origin }; } updateParentUrl(); }); addEventListener('vaadin-router-location-changed', updateParentUrl); ================================================ FILE: demo/@helpers/nested-styles.css ================================================ :host { display: flex; flex-direction: column; background: #ddd; padding: 5px; min-height: 100vh; } main { margin-top: 10px; padding: 5px; background: #fff; flex: 1 0 auto; min-height: 80px; } ================================================ FILE: demo/@helpers/page.css ================================================ code { background: var(--code-background); padding: 0.2em 0.4em; } ================================================ FILE: demo/@helpers/shared-styles.css ================================================ .note { background-color: #24c0ea; padding: 1em; color: white; border-radius: 4px; } ================================================ FILE: demo/@helpers/theme-controller.ts ================================================ import type { ReactiveController, ReactiveControllerHost } from 'lit'; export default class ThemeController implements ReactiveController { readonly #host: ReactiveControllerHost; #controller?: AbortController; constructor(host: ReactiveControllerHost) { (this.#host = host).addController(this); } get value(): string { return document.documentElement.getAttribute('theme') ?? 'light'; } hostConnected(): void { this.#controller = new AbortController(); addEventListener('theme-changed', () => this.#host.requestUpdate(), { signal: this.#controller.signal }); } hostDisconnected(): void { this.#controller?.abort(); } } ================================================ FILE: demo/@helpers/vaadin-demo-code-snippet-file.css ================================================ header { margin: 1.5em 0 0.5em; font-size: 0.75rem; display: flex; justify-content: space-between; align-items: baseline; background: var(--code-file-background); } section { font-size: 0.75rem; background: var(--code-background); padding: .5em; } section > pre { white-space: pre-wrap; } section > pre > code { line-break: anywhere; } .title { font-family: monospace; } .buttons { clear: right; float: right; display: flex; margin: -0.25em 0; } .buttons > vaadin-button { margin: 0; padding: 0; } /* ======= Highlight.js styles ======= */ :host([theme~='dark']) { @nested-import 'highlight.js/styles/atom-one-dark.css'; } :host([theme~='light']) { @nested-import 'highlight.js/styles/vs.css'; } pre { margin: 0; padding: 0.5em 1em; } ================================================ FILE: demo/@helpers/vaadin-demo-code-snippet-file.ts ================================================ /* eslint-disable @typescript-eslint/unbound-method */ import '@vaadin/button/src/vaadin-button'; import '@vaadin/icon/src/vaadin-icon'; import { type TemplateResult, LitElement, html, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { map } from 'lit/directives/map.js'; import ThemeController from './theme-controller.js'; import css from './vaadin-demo-code-snippet-file.css?ctr'; export type CodeSnippet = Readonly<{ id?: string; code: [original: string, full: TemplateResult, ...rest: TemplateResult[]]; title?: string; }>; @customElement('vaadin-demo-code-snippet-file') export default class DemoCodeSnippetFile extends LitElement { static override styles = [css]; @property({ attribute: false }) accessor file: CodeSnippet | undefined; @state() accessor #expanded = false; readonly #theme = new ThemeController(this); override updated(): void { this.setAttribute('theme', this.#theme.value); } override render(): TemplateResult | typeof nothing { if (!this.file) { return nothing; } const [_, full, ...snippets] = this.file.code; return html`
${this.file.title}
${this.#expanded ? html`
${full}
` : map(snippets, (snippet) => html`
${snippet}
`)}
`; } #toggleExpanded(): void { this.#expanded = !this.#expanded; } async #copyToClipboard(): Promise { const [original] = this.file?.code ?? []; if (original) { await navigator.clipboard.writeText(original); } } } declare global { interface HTMLElementTagNameMap { 'vaadin-demo-code-snippet-file': DemoCodeSnippetFile; } } ================================================ FILE: demo/@helpers/vaadin-demo-code-snippet.css ================================================ :host { display: block; } ================================================ FILE: demo/@helpers/vaadin-demo-code-snippet.ts ================================================ /* eslint-disable import/no-duplicates */ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import type { CodeSnippet } from './vaadin-demo-code-snippet-file.js'; import './vaadin-demo-code-snippet-file.js'; import css from './vaadin-demo-code-snippet.css?ctr'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-code-snippet': DemoCodeSnippet; } interface WindowEventMap { 'theme-changed': Event; } } export type { CodeSnippet }; function renderFile(file: CodeSnippet): TemplateResult { return html``; } @customElement('vaadin-demo-code-snippet') export default class DemoCodeSnippet extends LitElement { static override styles = [css]; @property({ attribute: false }) accessor files: readonly CodeSnippet[] = []; override render(): TemplateResult { switch (this.files.length) { case 0: return html``; case 1: return renderFile(this.files[0]); default: return html` ${repeat(this.files, ({ id }) => id, renderFile)} `; } } } ================================================ FILE: demo/@helpers/vaadin-demo-layout.css ================================================ :host { --code-file-background: none; } :host([theme~='dark']) { --code-background: var(--lumo-shade); } :host([theme~='light']) { --code-background: var(--lumo-shade-5pct); } main { max-width: 40rem; margin: 0 auto; padding: .5rem; } .navbar { display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 0 1rem; } ================================================ FILE: demo/@helpers/vaadin-demo-layout.ts ================================================ /* eslint-disable @typescript-eslint/unbound-method */ import '@vaadin/app-layout'; import '@vaadin/app-layout/vaadin-drawer-toggle.js'; import '@vaadin/icon'; import '@vaadin/icons'; import '@vaadin/scroller'; import '@vaadin/side-nav'; import '@vaadin/side-nav/vaadin-side-nav-item.js'; import { SignalWatcher } from '@lit-labs/preact-signals'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import './vaadin-presentation.js'; import css from './vaadin-demo-layout.css?ctr'; const colorScheme = window.localStorage.getItem('color-scheme'); if (colorScheme) { document.documentElement.setAttribute('theme', colorScheme); } declare global { interface HTMLElementTagNameMap { 'vaadin-demo-layout': DemoLayout; } } @customElement('vaadin-demo-layout') export default class DemoLayout extends SignalWatcher(LitElement) { static override styles = [css]; @property({ attribute: 'app-title', type: String }) accessor appTitle = ''; @property({ reflect: true, type: String }) accessor theme = document.documentElement.getAttribute('theme') ?? 'light'; override render(): TemplateResult { return html`
Getting Started Code Splitting Animated Transitions Lifecycle Callback Navigation Trigger Redirect Route Actions Route Parameters URL Generations API
`; } #onToggleMode() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; window.localStorage.setItem('color-scheme', this.theme); document.documentElement.setAttribute('theme', this.theme); dispatchEvent(new Event('theme-changed')); } } ================================================ FILE: demo/@helpers/vaadin-presentation-addressbar.css ================================================ :host { display: flex; gap: 1rem; flex: 0 1 0; } :host > :last-child { flex: 1 0 auto; } ================================================ FILE: demo/@helpers/vaadin-presentation-addressbar.ts ================================================ /* eslint-disable @typescript-eslint/unbound-method */ import '@vaadin/button'; import '@vaadin/icon'; import '@vaadin/icons'; import '@vaadin/text-field'; import '@vaadin/tooltip'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import css from './vaadin-presentation-addressbar.css?ctr'; declare global { interface HTMLElementTagNameMap { 'vaadin-presentation-addressbar': PresentationAddressbar; } } function onBack() { history.back(); } function onForward() { history.forward(); } @customElement('vaadin-presentation-addressbar') export class PresentationAddressbar extends LitElement { static override styles = css; @property({ attribute: true, type: String }) accessor url: string | undefined; override render(): TemplateResult { return html` `; } #onChange(event: Event) { this.url = (event.target as HTMLInputElement).value; this.dispatchEvent(new CustomEvent('url-changed', { detail: this.url })); } } ================================================ FILE: demo/@helpers/vaadin-presentation.css ================================================ :host { display: flex; flex-direction: column; gap: 0.5rem; } vaadin-presentation-addressbar, iframe { width: 100%; height: 15rem; } iframe { margin: 0; } ================================================ FILE: demo/@helpers/vaadin-presentation.ts ================================================ /* eslint-disable @typescript-eslint/unbound-method */ import { html, LitElement, type PropertyValues, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import css from './vaadin-presentation.css?ctr'; import './vaadin-presentation-addressbar.js'; import './vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-presentation': Presentation; } } type MessageData = Readonly<{ url: string; }>; @customElement('vaadin-presentation') export default class Presentation extends LitElement { static override styles = css; @property() accessor src: string | undefined; @property({ attribute: true, type: String }) accessor url: string | undefined; #controller?: AbortController; #window?: Window; override connectedCallback(): void { super.connectedCallback(); this.#controller = new AbortController(); } override disconnectedCallback(): void { super.disconnectedCallback(); this.#controller?.abort(); } override firstUpdated(): void { if (this.#controller) { addEventListener( 'message', ({ data, origin, source }: MessageEvent) => { if (origin === location.origin && source === this.#window) { this.url = new URL(data.url).pathname; } }, { signal: this.#controller.signal }, ); } } changedProperties(map: PropertyValues): void { if (map.has('url')) { this.#window?.postMessage({ url: this.url }, '*'); } } override render(): TemplateResult { return html` `; } #onUrlChanged(event: CustomEvent) { this.url = new URL(event.detail, location.origin).pathname; this.#window?.postMessage({ url: this.url }, '*'); } } ================================================ FILE: demo/@helpers/x-breadcrumbs.ts ================================================ import { html, LitElement, nothing, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { map } from 'lit/directives/map.js'; import css from './common.css?ctr'; export type Breadcrumb = Readonly<{ title: string; href: string; }>; @customElement('x-breadcrumbs') export class Breadcrumbs extends LitElement { static override styles = css; @property({ type: Array }) accessor items: readonly Breadcrumb[] = []; // eslint-disable-next-line @typescript-eslint/class-methods-use-this #isNotLastIndexOf(items: readonly Breadcrumb[], i: number): boolean { return i < items.length - 1; } override render(): TemplateResult { return html`
`; } } declare global { interface HTMLElementTagNameMap { 'x-breadcrumbs': Breadcrumbs; } } ================================================ FILE: demo/@helpers/x-home-view.ts ================================================ import { LitElement, html, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import css from './common.css?ctr'; declare global { interface HTMLElementTagNameMap { 'x-home-view': HomeView; } } @customElement('x-home-view') export default class HomeView extends LitElement { static override styles = css; override render(): TemplateResult { return html`

Home

`; } } ================================================ FILE: demo/@helpers/x-image-view.css ================================================ .img-view { width: var(--x-image-view-width, 100%); height: var(--x-image-view-height, 100%); background-color: var(--x-image-view-background-color, hotpink); } ================================================ FILE: demo/@helpers/x-image-view.ts ================================================ import { LitElement, html, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import type { RouterLocation } from '../../src/index.js'; import commonCss from './common.css?ctr'; import css from './x-image-view.css?ctr'; declare global { interface HTMLElementTagNameMap { 'x-image-view': ImageView; } } @customElement('x-image-view') export default class ImageView extends LitElement { static override styles = [commonCss, css]; @property({ attribute: false }) accessor location: RouterLocation | undefined; override render(): TemplateResult { const size = this.location?.params.size as number | undefined; const color = this.location?.params.color as string | undefined; return html`
`; } } ================================================ FILE: demo/@helpers/x-knowledge-base.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RouterLocation, WebComponentInterface } from '@vaadin/router'; @customElement('x-knowledge-base') export class KnowledgeBase extends LitElement implements WebComponentInterface { @property({ type: Object }) accessor location: RouterLocation | undefined; override render(): TemplateResult { return html` Knowledge base path: '${this.location?.params.path ?? 'unknown'}' `; } } declare global { interface HTMLElementTagNameMap { 'x-knowledge-base': KnowledgeBase; } } ================================================ FILE: demo/@helpers/x-login-view.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import css from './common.css?ctr'; import type { RouterLocation, WebComponentInterface } from '@vaadin/router'; @customElement('x-login-view') export default class LoginView extends LitElement implements WebComponentInterface { static override styles = [css]; location?: RouterLocation; override render(): TemplateResult { return html`

Login Form

`; } #login() { window.authorized = true; dispatchEvent( new CustomEvent('vaadin-router-go', { detail: { pathname: this.location?.params.to ?? '/' }, }), ); } } declare global { interface HTMLElementTagNameMap { 'x-login-view': LoginView; } } ================================================ FILE: demo/@helpers/x-not-found-view.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import css from './common.css?ctr'; @customElement('x-not-found-view') export class NotFoundView extends LitElement { static override styles = css; override render(): TemplateResult { return html`

404

View not found

`; } } declare global { interface HTMLElementTagNameMap { 'x-not-found-view': NotFoundView; } } ================================================ FILE: demo/@helpers/x-profile-view.ts ================================================ import { LitElement, html, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import css from './common.css?ctr'; import type { RouterLocation } from '@vaadin/router'; declare global { interface HTMLElementTagNameMap { 'x-profile-view': ProfileView; } } @customElement('x-profile-view') export default class ProfileView extends LitElement { static override styles = [css]; @property({ type: Object }) accessor location: RouterLocation | undefined; override render(): TemplateResult { return html`User ID: ${this.location?.params.id ?? 'unknown'}
/user or /users: ${this.location?.params[0] ?? 'unknown'}`; } } ================================================ FILE: demo/@helpers/x-user-list.ts ================================================ import { LitElement, html, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import css from './common.css?ctr'; declare global { interface HTMLElementTagNameMap { 'x-user-list': UserList; } } @customElement('x-user-list') export default class UserList extends LitElement { static override styles = css; override render(): TemplateResult { return html`

Users

`; } } ================================================ FILE: demo/@helpers/x-user-not-found-view.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import css from './common.css?ctr'; import type { RouterLocation } from '@vaadin/router'; @customElement('x-user-not-found-view') export class UserNotFoundView extends LitElement { @property({ type: Object }) accessor location: RouterLocation | undefined; static override styles = [css]; override render(): TemplateResult { return html`

The princess is in another castle

You've come to ${this.location?.pathname}, but alas, there is nothing there.

`; } } declare global { interface HTMLElementTagNameMap { 'x-user-not-found-view': UserNotFoundView; } } ================================================ FILE: demo/@helpers/x-user-numeric-view.ts ================================================ import { LitElement, html, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import css from './common.css?ctr'; import type { RouterLocation } from '@vaadin/router'; declare global { interface HTMLElementTagNameMap { 'x-user-numeric-view': UserNumericView; } } @customElement('x-user-numeric-view') export default class UserNumericView extends LitElement { static override styles = [css]; @property({ type: Object }) accessor location: RouterLocation | undefined; override render(): TemplateResult { return html`

User Profile

ID: ${this.location?.params[0] ?? 'unknown'}

`; } } ================================================ FILE: demo/@helpers/x-user-profile.ts ================================================ import { LitElement, html, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import css from './common.css?ctr'; import type { RouterLocation } from '@vaadin/router'; declare global { interface HTMLElementTagNameMap { 'x-user-profile': UserProfile; } } @customElement('x-user-profile') export default class UserProfile extends LitElement { static override styles = css; @property({ attribute: false }) accessor location: RouterLocation | undefined; override render(): TemplateResult { return html`

User Profile

Name: ${this.location?.params.user ?? 'unknown'}

`; } } ================================================ FILE: demo/animated-transitions/d1/iframe.html ================================================ Animated Transitions Home Image Users
================================================ FILE: demo/animated-transitions/d1/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-image-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', animate: true, children: [ { path: '', component: 'x-home-view' }, { path: '/image-:size(\\d+)px', component: 'x-image-view' }, { path: '/users', component: 'x-user-list' }, { path: '/users/:user', component: 'x-user-profile' }, ], }, ]); // end::snippet[] ================================================ FILE: demo/animated-transitions/d1/styles.css ================================================ #outlet > .leaving { animation: 1s fadeOut ease-in-out; } #outlet > .entering { animation: 1s fadeIn linear; } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } ================================================ FILE: demo/animated-transitions/d2/iframe.html ================================================ Animated Transitions
================================================ FILE: demo/animated-transitions/d2/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import { Router } from '@vaadin/router'; import './x-wrapper.js'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-wrapper', children: [ { path: '/users', animate: { enter: 'users-entering', leave: 'users-leaving', }, children: [ { path: '', component: 'x-user-list' }, { path: '/:user', component: 'x-user-profile', animate: true, }, ], }, { path: '(.*)', redirect: '/users' }, ], }, ]); // end::snippet[] ================================================ FILE: demo/animated-transitions/d2/styles.css ================================================ .leaving { animation: 1s slideOutDown ease-in-out; } .entering { animation: 1s slideInDown linear; } .users-entering { animation: 0.5s fadeIn ease-in; } .users-leaving { animation: 0.5s fadeOut linear; } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideInDown { from { transform: translate3d(0, -100%, 0); visibility: visible; } to { transform: translate3d(0, 0, 0); } } @keyframes slideOutDown { from { transform: translate3d(0, 0, 0); } to { visibility: hidden; transform: translate3d(0, 100%, 0); } } ================================================ FILE: demo/animated-transitions/d2/x-wrapper.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import sharedCss from '@helpers/shared-styles.css?ctr'; declare global { interface HTMLElementTagNameMap { 'x-wrapper': Wrapper; } } // tag::snippet[] @customElement('x-wrapper') export default class Wrapper extends LitElement { static override styles = sharedCss; override render(): TemplateResult { return html`
`; } } // end::snippet[] ================================================ FILE: demo/animated-transitions/index.html ================================================ Animated Transitions ================================================ FILE: demo/animated-transitions/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-demo-layout.js'; import '@helpers/vaadin-demo-code-snippet.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import htmlCode1 from './d1/iframe.html?snippet'; import tsCode1 from './d1/script.js?snippet'; import cssCode1 from './d1/styles.css?snippet'; import htmlCode2 from './d2/iframe.html?snippet'; import tsCode2 from './d2/script.js?snippet'; import cssCode2 from './d2/styles.css?snippet'; import wrapperCode from './d2/x-wrapper.ts?snippet'; import css from '@helpers/page.css?ctr'; import type { CodeSnippet } from '@helpers/vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-animated-transitions': DemoAnimatedTransitions; } } const files1: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode1, title: 'index.html', }, { id: 'ts', code: tsCode1, title: 'script.ts', }, { id: 'css', code: cssCode1, title: 'styles.css', }, ]; const files2: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode2, title: 'index.html', }, { id: 'ts', code: tsCode2, title: 'script.ts', }, { id: 'css', code: cssCode2, title: 'styles.css', }, { id: 'wrapper', code: wrapperCode, title: 'x-wrapper.ts', }, ]; @customElement('vaadin-demo-animated-transitions') export default class DemoAnimatedTransitions extends LitElement { static override styles = [css]; override render(): TemplateResult { return html`

Vaadin Router allows you to animate transitions between routes. In order to add an animation, do the next steps:

  1. update the router config: add the animate property set to true
  2. add @keyframes animations, either in the view Web Component styles or in outside CSS
  3. apply CSS for .leaving and .entering classes to use the animations

The demo below illustrates how to add the transition between all the routes in the same group. You might also add the transition for the specific routes only, by setting the animate property on the corresponding route config objects.

To run the animated transition, Vaadin Router performs the actions in the following order:

  1. render the new view component to the outlet content
  2. set the entering CSS class on the new view component
  3. set the leaving CSS class on the old view component, if any
  4. check if some @keyframes animation applies, and wait for it to complete
  5. remove the old view component from the outlet content
  6. continue the remaining navigation steps as usual

Customize CSS Classes

In the basic use case, using single type of the animated transition could be enough to make the web app looking great, but often we need to configure it depending on the route. Vaadin Router supports this feature by setting object value to animate property, with the enter and leave string keys. Their values are used for setting CSS classes to be set on the views.

Note that you can first configure animated transition for the group of routes, and then override it for the single route. In particular, you can switch back to using default CSS classes, as shown in the demo below.

`; } } ================================================ FILE: demo/code-splitting/d1/iframe.html ================================================ Code Splitting Home User Profile
================================================ FILE: demo/code-splitting/d1/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/user/:id', async action() { await import(`./user.bundle.js`); }, component: 'x-user-js-bundle-view', // <-- defined in the bundle }, ]); // end::snippet[] ================================================ FILE: demo/code-splitting/d1/user.bundle.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RouterLocation } from '../../../src/index.js'; import css from '@helpers/common.css?ctr'; declare global { interface HTMLElementTagNameMap { 'x-user-js-bundle-view': UserJsBundleView; } } // tag::snippet[] @customElement('x-user-js-bundle-view') export default class UserJsBundleView extends LitElement { static override styles = css; @property({ attribute: false }) accessor location: RouterLocation | undefined; override render(): TemplateResult { return html`

User JS Bundle

User id: ${this.location?.params.id}. This view was loaded using JS bundle.

`; } } // end::snippet[] ================================================ FILE: demo/code-splitting/d2/iframe.html ================================================ Code Splitting Home Users
================================================ FILE: demo/code-splitting/d2/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-image-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', async children() { return await import('./user-routes.js').then((mod) => mod.default); }, }, ]); // end::snippet[] ================================================ FILE: demo/code-splitting/d2/user-routes.ts ================================================ import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import type { Route } from '@vaadin/router'; // tag::snippet[] const usersRoutes: readonly Route[] = [ { path: '/', component: 'x-user-list' }, { path: '/:user', component: 'x-user-profile' }, ]; // end::snippet[] export default usersRoutes; ================================================ FILE: demo/code-splitting/index.html ================================================ Code Splitting ================================================ FILE: demo/code-splitting/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-demo-code-snippet.js'; import '@helpers/vaadin-demo-layout.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import htmlCode1 from './d1/iframe.html?snippet'; import tsCode1 from './d1/script.js?snippet'; import bundleCode from './d1/user.bundle.js?snippet'; import htmlCode2 from './d2/iframe.html?snippet'; import tsCode2 from './d2/script.js?snippet'; import userRoutesCode from './d2/user-routes.js?snippet'; import css from '@helpers/page.css?ctr'; import type { CodeSnippet } from '@helpers/vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-code-splitting': DemoCodeSplitting; } } const files1: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode1, title: 'iframe1.html', }, { id: 'ts', code: tsCode1, title: 'script1.ts', }, { id: 'bundle', code: bundleCode, title: 'user-bundle.ts', }, ]; const files2: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode2, title: 'iframe2.html', }, { id: 'ts', code: tsCode2, title: 'script2.ts', }, { id: 'routes', code: userRoutesCode, title: 'user-routes.ts', }, ]; @customElement('vaadin-demo-code-splitting') export default class DemoCodeSplitting extends LitElement { static override styles = [css]; override render(): TemplateResult { return html`

Using Dynamic Imports

Vaadin Router allows you to implement your own loading mechanism for bundles using custom Route Actions. In that case, you can use dynamic imports.

Note: If the dynamically loaded route has lifecycle callbacks, the action should return a promise that resolves only when the route component is loaded (like in the example below). Otherwise the lifecycle callbacks on the dynamically loaded route's web component are not called.

If dynamic imports are used both for parent and child routes, then the example above may possibly slow down rendering because router would not start importing a child component until its parent is imported.

Splitting and Lazy-Loading the Routes Configuration

Vaadin Router supports splitting the routes configuration object into parts and lazily loading them on-demand, enabling developers to create non-monolithic app structures. This might be useful for implementing a distributed sub routes configuration within a big project, so that multiple teams working on different parts of the app no longer have to merge their changes into the same file.

The children property on the route config object can be set to a function, which returns an array of the route objects. It may return a Promise, which allows to dynamically import the configuration file, and return the children array exported from it.

See the API documentation for detailed description of the children callback function.

`; } } ================================================ FILE: demo/getting-started/d1/iframe.html ================================================ Getting Started Home User Profile
================================================ FILE: demo/getting-started/d1/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-not-found-view.js'; import '@helpers/x-not-found-view.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', component: 'x-user-list' }, { path: '/users/(.*)', component: 'x-user-not-found-view' }, { path: '(.*)', component: 'x-not-found-view' }, ]); // end::snippet[] ================================================ FILE: demo/getting-started/index.html ================================================ Getting Started ================================================ FILE: demo/getting-started/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-demo-layout.js'; import '@helpers/vaadin-demo-code-snippet.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import htmlCode1 from './d1/iframe.html?snippet'; import tsCode1 from './d1/script.js?snippet'; import htmlSnippet1 from './snippets/s1.html?snippet'; import tsSnippet1 from './snippets/s2.ts?snippet'; import tsSnippet2 from './snippets/s4.ts?snippet'; import css from '@helpers/page.css?ctr'; import type { CodeSnippet } from '@helpers/vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-getting-started': DemoGettingStarted; } } const files1: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode1, title: 'index.html', }, { id: 'ts', code: tsCode1, title: 'script.ts', }, ]; @customElement('vaadin-demo-getting-started') export default class DemoGettingStarted extends LitElement { static override styles = [css]; override render(): TemplateResult { return html`

The Router class

The Router class is the only thing you need to get started with Vaadin Router. Depending on your project setup, there are several ways to access it.

In modern browsers that support ES modules the Router class can be imported directly into a script tag on a page:

In Vite / Webpack / Rollup projects the Router class can be imported from the @vaadin/router npm package:

Getting Started

Vaadin Router automatically listens to navigation events and asynchronously renders a matching Web Component into the given DOM node (a.k.a. the router outlet). By default, navigation events are triggered by popstate events on the window, and by click events on <a> elements on the page.

The routes config maps URL paths to Web Components. Vaadin Router goes through the routes until it finds the first match, creates an instance of the route component, and inserts it into the router outlet (replacing any pre-existing outlet content). For details on the route path syntax see the Route Parameters demos.

Route components can be any Web Components regardless of how they are built: vanilla JavaScript, Lit, Stencil, SkateJS, Angular, Vue, etc.

Vaadin Router follows the lifecycle callbacks convention described in WebComponentInterface: if a route component defines any of these callback methods, Vaadin Router will call them at the appropriate points in the navigation lifecycle. See the Lifecycle Callbacks section for more details.

In addition to that Vaadin Router also sets a location property on every route Web Component so that you could access the current location details via an instance property (e.g. this.location.pathname).

Using this.location

For LitElement and TypeScript a declaration in the component is required. Declare the location property in the class and initialize it from the router Vaadin Router instance:

This property is automatically updated on navigation.

Fallback Routes (404)

If Vaadin Router does not find a matching route, the promise returned from the render() method gets rejected, and any content in the router outlet is removed. In order to show a user-friendly 'page not found' view, a fallback route with a wildcard '(.*)' path can be added to the end of the routes list.

There can be different fallbacks for different route prefixes, but since the route resolution is based on the first match, the fallback route should always be after other alternatives.

The path that leads to the fallback route is available to the route component via the location.pathname property.

`; } } ================================================ FILE: demo/getting-started/snippets/s1.html ================================================ Snippets ================================================ FILE: demo/getting-started/snippets/s2.ts ================================================ // tag::snippet[] import { Router } from '@vaadin/router'; // end::snippet[] export const router = new Router(); ================================================ FILE: demo/getting-started/snippets/s4.ts ================================================ /* eslint-disable import/order, import/no-duplicates */ // tag::snippet[] import type { RouterLocation } from '@vaadin/router'; import { html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { router } from './s2.js'; @customElement('my-view') class MyViewElement extends LitElement { @property({ type: Object }) accessor location: RouterLocation = router.location; override render() { return html`Current location URL: ${this.location.getUrl()}`; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'my-view': MyViewElement; } } ================================================ FILE: demo/index.html ================================================ ================================================ FILE: demo/index.ts ================================================ import '@vaadin/vaadin-lumo-styles/all-imports.js'; import '@helpers/vaadin-demo-layout.js'; if (window.matchMedia('(prefers-color-scheme: dark)').matches) { document.documentElement.setAttribute('theme', 'dark'); } ================================================ FILE: demo/lifecycle-callback/d1/iframe.html ================================================ Lifecycle Callback Home Are you ready?
================================================ FILE: demo/lifecycle-callback/d1/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import { Router } from '@vaadin/router'; import './x-countdown.js'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/go', component: 'x-countdown' }, ]); // end::snippet[] ================================================ FILE: demo/lifecycle-callback/d1/x-countdown.ts ================================================ import { html, LitElement, render, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import type { RouterLocation, WebComponentInterface } from '../../../src/types.t.js'; // tag::snippet[] @customElement('x-countdown') export default class Countdown extends LitElement implements WebComponentInterface { readonly #home = document.body.querySelector('x-home-view'); #count = 0; #timer?: ReturnType; override render(): TemplateResult { return html`

Go-go-go!

`; } async onBeforeEnter(_: RouterLocation): Promise { this.#count = 3; this.#tick(); return await new Promise((resolve) => { this.#timer = setInterval(() => { if (this.#count < 0) { this.#clear(); resolve(); } else { this.#tick(); } }, 500); }); } #tick(): void { if (this.#home) { render(html`

${this.#count}

`, this.#home); } this.#count -= 1; } #clear(): void { if (this.#home) { render(html``, this.#home); } clearInterval(this.#timer); } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-countdown': Countdown; } } ================================================ FILE: demo/lifecycle-callback/d2/iframe.html ================================================ Lifecycle Callback Home Meet a friend
================================================ FILE: demo/lifecycle-callback/d2/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import { Router } from '@vaadin/router'; import './x-friend.js'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/friend', component: 'x-friend' }, ]); // end::snippet[] ================================================ FILE: demo/lifecycle-callback/d2/x-friend.ts ================================================ import { css, html, LitElement, render, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import type { RouterLocation, WebComponentInterface } from '../../../src/types.t.js'; // tag::snippet[] @customElement('x-friend') export default class Friend extends LitElement implements WebComponentInterface { static override styles = css` ::slotted(h2) { color: red !important; } `; override render(): TemplateResult { return html`
`; } onAfterEnter(_: RouterLocation): void { render(html`

I am here!

`, this); } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-friend': Friend; } } ================================================ FILE: demo/lifecycle-callback/d3/iframe.html ================================================ Lifecycle Callback
================================================ FILE: demo/lifecycle-callback/d3/script.ts ================================================ import '@helpers/iframe.script.js'; import { Router } from '@vaadin/router'; import './x-user-deleted.js'; import './x-user-manage.js'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', redirect: '/user/guest/manage' }, { path: '/user/:user/manage', component: 'x-user-manage' }, { path: '/user/delete', component: 'x-user-deleted' }, ]); // end::snippet[] ================================================ FILE: demo/lifecycle-callback/d3/x-user-deleted.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import type { WebComponentInterface } from '../../../src/types.t.js'; // tag::snippet[] @customElement('x-user-deleted') export default class UserDeleted extends LitElement implements WebComponentInterface { override render(): TemplateResult { return html`
User has been deleted.
`; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-user-deleted': UserDeleted; } } ================================================ FILE: demo/lifecycle-callback/d3/x-user-manage.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RouterLocation, PreventAndRedirectCommands, WebComponentInterface, PreventResult } from '@vaadin/router'; // tag::snippet[] @customElement('x-user-manage') export default class UserManage extends LitElement implements WebComponentInterface { @property({ type: Object }) accessor location: RouterLocation | undefined; override render(): TemplateResult { return html`

Manage user

User name: ${this.location?.params.user}

Delete user
`; } onBeforeLeave(location: RouterLocation, commands: PreventAndRedirectCommands): PreventResult | undefined { if (location.pathname.indexOf('user/delete') > 0) { // eslint-disable-next-line no-alert if (!window.confirm('Are you sure you want to delete this user?')) { return commands.prevent(); } } return undefined; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-user-manage': UserManage; } } ================================================ FILE: demo/lifecycle-callback/d4/iframe.html ================================================ Lifecycle Callback
================================================ FILE: demo/lifecycle-callback/d4/script.ts ================================================ import '@helpers/iframe.script.js'; import { Router } from '@vaadin/router'; import './x-main-page.js'; import './x-autosave-view.js'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-main-page' }, { path: '/edit', component: 'x-autosave-view' }, ]); // end::snippet[] ================================================ FILE: demo/lifecycle-callback/d4/x-autosave-view.ts ================================================ /* eslint-disable @typescript-eslint/unbound-method */ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import type { WebComponentInterface } from '../../../src/types.t.js'; let savedText = 'This text is automatically saved when router navigates away.'; // tag::snippet[] @customElement('x-autosave-view') export class AutosaveView extends LitElement implements WebComponentInterface { @state() accessor #text = savedText; override render(): TemplateResult { return html`
Stop editing `; } onInput(event: Event): void { const target = event.target as HTMLTextAreaElement; this.#text = target.value; } onAfterEnter(): void { this.#text = savedText; } onAfterLeave(): void { savedText = this.#text; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-autosave-view': AutosaveView; } } ================================================ FILE: demo/lifecycle-callback/d4/x-main-page.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; // tag::snippet[] @customElement('x-main-page') export class MainPage extends LitElement { override render(): TemplateResult { return html`Edit the text`; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-main-page': MainPage; } } ================================================ FILE: demo/lifecycle-callback/d5/iframe.html ================================================ Lifecycle Callback Home All Users Kim About
================================================ FILE: demo/lifecycle-callback/d5/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import '@helpers/x-not-found-view.js'; import { Router } from '@vaadin/router'; // tag::snippet[] window.addEventListener('vaadin-router-location-changed', (event) => { const breadcrumbs = document.querySelector('#breadcrumbs')!; breadcrumbs.textContent = `You are at '${event.detail.location.pathname}'`; }); const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', component: 'x-user-list' }, { path: '/users/:user', component: 'x-user-profile' }, { path: '(.*)', component: 'x-not-found-view' }, ]); // end::snippet[] ================================================ FILE: demo/lifecycle-callback/d6/iframe.html ================================================ Lifecycle Callback All Users Kim About
================================================ FILE: demo/lifecycle-callback/d6/script.ts ================================================ /* eslint-disable import/no-duplicates */ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import '@helpers/x-breadcrumbs.js'; import '@helpers/x-not-found-view.js'; import type { Breadcrumb } from '@helpers/x-breadcrumbs.js'; import { Router, type VaadinRouterLocationChangedEvent } from '@vaadin/router'; // tag::snippet[] type RouteExtension = Readonly<{ xBreadcrumb?: Breadcrumb; }>; window.addEventListener('vaadin-router-location-changed', (event: VaadinRouterLocationChangedEvent) => { const { router, location: { params }, } = event.detail; const breadcrumbs = document.querySelector('x-breadcrumbs')!; breadcrumbs.items = router.location.routes .map((route) => route.xBreadcrumb) .filter((xBreadcrumb) => xBreadcrumb != null) .map(({ href, title }) => ({ title: title.replace(/:user/u, params.user as string), href: href.replace(/:user/u, params.user as string), })); }); const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', xBreadcrumb: { title: 'home', href: '/' }, children: [ { path: '/', component: 'x-home-view' }, { path: '/users', xBreadcrumb: { title: 'users', href: '/users' }, children: [ { path: '/', component: 'x-user-list' }, { path: '/:user', xBreadcrumb: { title: ':user', href: '/users/:user' }, component: 'x-user-profile' }, ], }, ], }, { path: '(.*)', component: 'x-not-found-view' }, ]); // end::snippet[] ================================================ FILE: demo/lifecycle-callback/index.html ================================================ Lifecycle Callback ================================================ FILE: demo/lifecycle-callback/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-demo-layout.js'; import '@helpers/vaadin-demo-code-snippet.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import htmlCode1 from './d1/iframe.html?snippet'; import tsCode1 from './d1/script.js?snippet'; import xCountdownCode from './d1/x-countdown.js?snippet'; import htmlCode2 from './d2/iframe.html?snippet'; import tsCode2 from './d2/script.js?snippet'; import xFriend from './d2/x-friend.js?snippet'; import htmlCode3 from './d3/iframe.html?snippet'; import tsCode3 from './d3/script.js?snippet'; import xUserDeleted from './d3/x-user-deleted.js?snippet'; import xUserManage from './d3/x-user-manage.js?snippet'; import htmlCode4 from './d4/iframe.html?snippet'; import tsCode4 from './d4/script.js?snippet'; import xAutosaveView from './d4/x-autosave-view.js?snippet'; import xMainPage from './d4/x-main-page.js?snippet'; import htmlCode5 from './d5/iframe.html?snippet'; import tsCode5 from './d5/script.js?snippet'; import htmlCode6 from './d6/iframe.html?snippet'; import tsCode6 from './d6/script.js?snippet'; import onAfterEnterCode from './snippets/my-view-with-after-enter.ts?snippet'; import onAfterLeaveCode from './snippets/my-view-with-after-leave.ts?snippet'; import onBeforeEnterCode from './snippets/my-view-with-before-enter.ts?snippet'; import onBeforeLeaveCode from './snippets/my-view-with-before-leave.ts?snippet'; import css from '@helpers/page.css?ctr'; import type { CodeSnippet } from '@helpers/vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-lifecycle-callback': DemoLifecycleCallback; } } const files1: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode1, title: 'iframe1.html', }, { id: 'ts', code: tsCode1, title: 'script1.ts', }, { id: 'x-countdown', code: xCountdownCode, title: 'x-countdown.ts', }, ]; const files2: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode2, title: 'iframe2.html', }, { id: 'ts', code: tsCode2, title: 'script2.ts', }, { id: 'x-friend', code: xFriend, title: 'x-friend.ts', }, ]; const files3: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode3, title: 'iframe3.html', }, { id: 'ts', code: tsCode3, title: 'script3.ts', }, { id: 'x-user-deleted', code: xUserDeleted, title: 'x-user-deleted.ts', }, { id: 'x-user-manage', code: xUserManage, title: 'x-user-manage.ts', }, ]; const files4: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode4, title: 'iframe4.html', }, { id: 'ts', code: tsCode4, title: 'script4.ts', }, { id: 'x-autosave-view', code: xAutosaveView, title: 'x-autosave-view.ts', }, { id: 'x-main-page', code: xMainPage, title: 'x-main-page.ts', }, ]; const files5: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode5, title: 'iframe5.html', }, { id: 'ts', code: tsCode5, title: 'script5.ts', }, ]; const files6: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode6, title: 'iframe6.html', }, { id: 'ts', code: tsCode6, title: 'script6.ts', }, ]; @customElement('vaadin-demo-lifecycle-callback') export default class DemoLifecycleCallback extends LitElement { static override styles = [css]; override render(): TemplateResult { return html`

Lifecycle Callbacks

Vaadin Router manages the lifecycle of all route Web Components. Lifecycle callbacks allow you to add custom logic to any point of this lifecycle:

Vaadin Router lifecycle callbacks can be defined as methods on the route Web Component class definition in a similar way as the native custom element callbacks (like disconnectedCallback()).

onBeforeEnter(location, commands, router)

The component's route has matched the current path, an instance of the component has been created and is about to be inserted into the DOM. Use this callback to create a route guard (e.g. redirect to the login page if the user is not logged in).

At this point there is yet no guarantee that the navigation into this view will actually happen because another route's callback may interfere.

This callback may return a redirect (return commands.redirect('/new/path')) or a prevent (return commands.prevent()) router command. If it returns a promise, the router waits until the promise is resolved before proceeding with the navigation.

See the API documentation for more details.

Note: Navigating to the same route also triggers this callback, e.g., click on the same link multiple times will trigger the onBeforeEnter callback on each click.

onAfterEnter(location, commands, router)

The component's route has matched the current path and an instance of the component has been rendered into the DOM. At this point it is certain that navigation won't be prevented or redirected. Use this callback to process route params and initialize the view so that it's ready for user interactions.

NOTE: When navigating between views the onAfterEnter callback on the new view's component is called before the onAfterLeave callback on the previous view's component (which is being removed). At some point both the new and the old view components are present in the DOM to allow animating the transition (you can listen to the animationend event to detect when it is over).

Any value returned from this callback is ignored. See the API documentation for more details.

onBeforeLeave(location, commands, router)

The component's route does not match the current path anymore and the component is about to be removed from the DOM. Use this callback to prevent the navigation if necessary like in the demo below. Note that the user is still able to copy and open that URL manually in the browser.

Even if this callback does not prevent the navigation, at this point there is yet no guarantee that the navigation away from this view will actually happen because another route's callback may also interfere.

This callback may return a prevent (return commands.prevent()) router command. If it returns a promise, the router waits until the promise is resolved before proceeding with the navigation.

See the API documentation for more details.

Note: Navigating to the same route also triggers this callback, e.g., click on the same link multiple times will trigger the onBeforeLeave callback on each click.

onAfterLeave(location, commands, router)

The component's route does not match the current path anymore and the component's removal from the DOM has been started (it will be removed after a transition animation, if any). At this point it is certain that navigation won't be prevented. Use this callback to clean-up and perform any custom actions that leaving a view should trigger. For example, the demo below auto-saves any unsaved changes when the user navigates away from the view.

NOTE: When navigating between views the onAfterEnter callback on the new view's component is called before the onAfterLeave callback on the previous view's component (which is being removed). At some point both the new and the old view components are present in the DOM to allow animating the transition (you can listen to the animationend event to detect when it is over).

Any value returned from this callback is ignored. See the API documentation for more details.

Listen to Global Navigation Events

In order to react to route changes in other parts of the app (outside of route components), you can add an event listener for the vaadin-router-location-changed events on the window. Vaadin Router dispatches such events every time after navigating to a new path.

In case if navigation fails for any reason (e.g. if no route matched the given pathname), instead of the vaadin-router-location-changed event Vaadin Router dispatches vaadin-router-error and attaches the error object to the event as event.detail.error.

Getting the Current Location

When handling Vaadin Router events, you can access the router instance via event.detail.router, and the current location via event.detail.location (which is a shorthand for event.detail.router.location). The location object has all details about the current router state. For example, location.routes is a read-only list of routes that correspond to the last completed navigation, which may be useful for example when creating a breadcrumbs component to visualize the current in-app location.

The router configuration allows you to add any custom properties to route objects. The example above uses that to set a custom xBreadcrumb property on the routes that we want to show up in breadcrumbs. That property is used later when processing the vaadin-router-location-changed events.

TypeScript Interfaces

For using with components defined as TypeScript classes, the following interfaces are available for implementing:

  • BeforeEnterObserver

  • AfterEnterObserver

  • BeforeLeaveObserver

  • AfterLeaveObserver

`; } } ================================================ FILE: demo/lifecycle-callback/snippets/my-view-with-after-enter.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import { LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; // tag::snippet[] import type { EmptyCommands, Router, RouterLocation, WebComponentInterface } from '@vaadin/router'; @customElement('my-view-with-after-enter') class MyViewWithAfterEnter extends LitElement implements WebComponentInterface { onAfterEnter(location: RouterLocation, commands: EmptyCommands, router: Router) { // ... } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'my-view-with-after-enter': MyViewWithAfterEnter; } } ================================================ FILE: demo/lifecycle-callback/snippets/my-view-with-after-leave.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import { LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; // tag::snippet[] import type { EmptyCommands, Router, RouterLocation, WebComponentInterface } from '@vaadin/router'; @customElement('my-view-with-after-leave') class MyViewWithAfterLeave extends LitElement implements WebComponentInterface { onAfterLeave(location: RouterLocation, commands: EmptyCommands, router: Router) { // ... } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'my-view-with-after-leave': MyViewWithAfterLeave; } } ================================================ FILE: demo/lifecycle-callback/snippets/my-view-with-before-enter.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import { LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; // tag::snippet[] import type { PreventAndRedirectCommands, Router, RouterLocation, WebComponentInterface } from '@vaadin/router'; @customElement('my-view-with-before-enter') export default class MyViewWithBeforeEnter extends LitElement implements WebComponentInterface { onBeforeEnter(location: RouterLocation, commands: PreventAndRedirectCommands, router: Router): void { // ... } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'my-view-with-before-enter': MyViewWithBeforeEnter; } } ================================================ FILE: demo/lifecycle-callback/snippets/my-view-with-before-leave.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import { LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; // tag::snippet[] import type { PreventCommands, Router, RouterLocation, WebComponentInterface } from '@vaadin/router'; @customElement('my-view-with-before-leave') class MyViewWithBeforeLeave extends LitElement implements WebComponentInterface { onBeforeLeave(location: RouterLocation, commands: PreventCommands, router: Router) { // ... } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'my-view-with-before-leave': MyViewWithBeforeLeave; } } ================================================ FILE: demo/navigation-trigger/d1/iframe.html ================================================ Navigation Trigger
================================================ FILE: demo/navigation-trigger/d1/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', component: 'x-user-list' }, ]); setInterval(() => { window.history.pushState(null, document.title, window.location.pathname === '/' ? '/users' : '/'); window.dispatchEvent(new PopStateEvent('popstate')); }, 3000); // end::snippet[] ================================================ FILE: demo/navigation-trigger/d2/iframe.html ================================================ Navigation Trigger Home Users
================================================ FILE: demo/navigation-trigger/d2/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', component: 'x-user-list' }, ]); // end::snippet[] ================================================ FILE: demo/navigation-trigger/d3/iframe.html ================================================ Navigation Trigger
  • Home
  • Users
================================================ FILE: demo/navigation-trigger/d3/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import { DEFAULT_TRIGGERS, Router } from '@vaadin/router'; // tag::snippet[] const { POPSTATE } = DEFAULT_TRIGGERS; Router.setTriggers(POPSTATE); document.querySelector('ul')?.addEventListener('click', (event) => { if (event.target instanceof HTMLLIElement && event.target.dataset.href) { window.history.pushState({}, '', event.target.dataset.href); window.dispatchEvent(new PopStateEvent('popstate')); } }); const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', component: 'x-user-list' }, ]); // end::snippet[] ================================================ FILE: demo/navigation-trigger/d4/iframe.html ================================================ Navigation Trigger Home Users
================================================ FILE: demo/navigation-trigger/d4/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', children: [ { path: '', component: 'x-user-list' }, { path: '/:user', component: 'x-user-profile' }, ], }, ]); router.unsubscribe(); // router will re-render only when the `render()` method is called explicitly: await router.render('/users'); // end::snippet[] ================================================ FILE: demo/navigation-trigger/index.html ================================================ Navigation Trigger ================================================ FILE: demo/navigation-trigger/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-demo-layout.js'; import '@helpers/vaadin-demo-code-snippet.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import htmlCode1 from './d1/iframe.html?snippet'; import tsCode1 from './d1/script.js?snippet'; import htmlCode2 from './d2/iframe.html?snippet'; import tsCode2 from './d2/script.js?snippet'; import htmlCode3 from './d3/iframe.html?snippet'; import tsCode3 from './d3/script.js?snippet'; import htmlCode4 from './d4/iframe.html?snippet'; import tsCode4 from './d4/script.js?snippet'; import css from '@helpers/page.css?ctr'; import type { CodeSnippet } from '@helpers/vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-navigation-trigger': DemoNavigationTrigger; } } const files1: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode1, title: 'index.html', }, { id: 'ts', code: tsCode1, title: 'script.js', }, ]; const files2: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode2, title: 'index.html', }, { id: 'ts', code: tsCode2, title: 'script.js', }, ]; const files3: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode3, title: 'index.html', }, { id: 'ts', code: tsCode3, title: 'script.js', }, ]; const files4: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode4, title: 'index.html', }, { id: 'ts', code: tsCode4, title: 'script.js', }, ]; @customElement('vaadin-demo-navigation-trigger') export default class DemoNavigationTrigger extends LitElement { static override styles = [css]; override render(): TemplateResult { return html`

Navigation Triggers

This feature is for advanced use cases. Please make sure to read the documentation carefully.

There are several events that can trigger in-app navigation with Vaadin Router: popstate events, clicks on the <a> elements, imperative navigation triggered by JavaScript. In order to make a flexible solution that can be tweaked to particular app needs and remain efficient, Vaadin Router has a concept of pluggable Navigation Triggers that listen to specific browser events and convert them into the Vaadin Router navigation events.

The @vaadin/router package comes with two Navigation Triggers bundled by default: POPSTATE and CLICK.

Developers can define and use additional Navigation Triggers that are specific to their application. A Navigation Trigger object must have two methods: activate() to start listening on some UI events, and inactivate() to stop.

NavigationTrigger.POPSTATE

The default POPSTATE navigation trigger for Vaadin Router listens to popstate events on the current window and for each of them dispatches a navigation event for Vaadin Router using the current window.location.pathname as the navigation target. This allows using the browser Forward and Back buttons for in-app navigation.

In the demo below the popstate events are dispatched at 3 seconds intervals. Before dispatching an event the global location.pathname is toggled between '/' and '/users'.

Please note that when using the window.history.pushState() or window.history.replaceState() methods, you need to dispatch the popstate event manually—these methods do not do that by themselves (see MDN for details).

NavigationTrigger.CLICK

The CLICK navigation trigger intercepts clicks on <a> elements on the the page and converts them into navigation events for Vaadin Router if they refer to a location within the app. That allows using regular <a> link elements for in-app navigation. You can use router-ignore attribute to have the router ignore the link.

Custom Navigation Triggers

The set of default navigation triggers can be changed using the Router.setTriggers() static method. It accepts zero, one or more NavigationTriggers.

This demo shows how to disable the CLICK navigation trigger. It may be useful when the app has an own custom element for in-app links instead of using the regular <a> elements for that purpose. The demo also shows a very basic version of a custom in-app link element based on a list element that fires popstate events when clicked.

Note: if the default Navigation Triggers are not used by the app, they can be excluded from the app bundle to avoid sending unnecessary code to the users. See src/router-config.js for details.

Unsubscribe from Navigation Events

Each Vaadin Router instance is automatically subscribed to navigation events upon creation. Sometimes it might be necessary to cancel this subscription so that the router would re-render only in response to the explicit render() method calls. Use the unsubscribe() method to cancel this automatic subscription and the subscribe() method to re-subscribe.

`; } } ================================================ FILE: demo/redirect/d1/iframe.html ================================================ Redirect Home User profile Knowledge Base
================================================ FILE: demo/redirect/d1/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-profile.js'; import '@helpers/x-knowledge-base.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/u/:user', redirect: '/user/:user' }, { path: '/user/:user', component: 'x-user-profile' }, { path: '/data/:segments+/:path+', redirect: '/kb/:path+' }, { path: '/kb/:path+', component: 'x-knowledge-base' }, ]); // end::snippet[] ================================================ FILE: demo/redirect/d2/iframe.html ================================================ Redirect Home Admin Login Logout
================================================ FILE: demo/redirect/d2/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-login-view.js'; import { Router } from '@vaadin/router'; import './x-admin-view.js'; // tag::snippet[] window.authorized = false; const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/admin', component: 'x-admin-view' }, { path: '/login/:to?', component: 'x-login-view' }, { path: '/logout', action(_, commands) { window.authorized = false; return commands.redirect('/'); }, }, ]); // end::snippet[] ================================================ FILE: demo/redirect/d2/x-admin-view.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import type { Commands, RedirectResult, RouterLocation, WebComponentInterface } from '@vaadin/router'; declare global { interface HTMLElementTagNameMap { 'x-admin-view': AdminView; } interface Window { authorized: boolean; } } // tag::snippet[] @customElement('x-admin-view') export default class AdminView extends LitElement implements WebComponentInterface { onBeforeEnter(location: RouterLocation, commands: Commands): RedirectResult | undefined { if (!window.authorized) { return commands.redirect(`/login/${encodeURIComponent(location.pathname)}`); } return undefined; } override render(): TemplateResult { return html`Secret admin stuff`; } } // end::snippet[] ================================================ FILE: demo/redirect/d3/iframe.html ================================================ Redirect
================================================ FILE: demo/redirect/d3/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-profile.js'; import { Router } from '@vaadin/router'; // tag::snippet[] document.querySelector('#trigger')?.addEventListener('click', () => { Router.go('/user/you-know-who'); }); const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/user/:user', component: 'x-user-profile' }, ]); // end::snippet[] ================================================ FILE: demo/redirect/index.html ================================================ Redirect ================================================ FILE: demo/redirect/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-demo-layout.js'; import '@helpers/vaadin-demo-code-snippet.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import htmlCode1 from './d1/iframe.html?snippet'; import tsCode1 from './d1/script.js?snippet'; import htmlCode2 from './d2/iframe.html?snippet'; import tsCode2 from './d2/script.js?snippet'; import htmlCode3 from './d3/iframe.html?snippet'; import tsCode3 from './d3/script.js?snippet'; import tsSnippet1 from './snippets/s1.ts?snippet'; import tsSnippet2 from './snippets/s2.ts?snippet'; import tsSnippet3 from './snippets/s3.ts?snippet'; import css from '@helpers/page.css?ctr'; import type { CodeSnippet } from '@helpers/vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-redirect': DemoRedirect; } } const files1: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode1, title: 'index.html', }, { id: 'ts', code: tsCode1, title: 'script.js', }, ]; const files2: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode2, title: 'index.html', }, { id: 'ts', code: tsCode2, title: 'script.js', }, ]; const files3: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode3, title: 'index.html', }, { id: 'ts', code: tsCode3, title: 'script.js', }, ]; @customElement('vaadin-demo-redirect') export default class DemoRedirect extends LitElement { static override styles = [css]; override render(): TemplateResult { return html`

Unconditional Redirects

Vaadin Router supports the redirect property on the route objects, allowing to unconditionally redirect users from one path to another. The valid values are a path string or a pattern in the same format as used for the path property.

The original path is not stored as the window.history entry and cannot be reached by pressing the "Back" browser button. Unconditional redirects work for routes both with and without parameters.

The original path is available to route Web Components as the location.redirectFrom string property, and to custom route actions – as context.redirectFrom.

Note: If a route has both the redirect and action properties, action is executed first and if it does not return a result Vaadin Router proceeds to check the redirect property. Other route properties (if any) would be ignored. In that case Vaadin Router would also log a warning to the browser console.

Dynamic Redirects

Vaadin Router allows redirecting to another path dynamically based on a condition evaluated at the run time. In order to do that, return commands.redirect('/new/path') from the onBeforeEnter() lifecycle callback of the route Web Component.

It is also possible to redirect from a custom route action. The demo below has an example of that in the /logout route action. See the Route Actions section for more details.

Navigation from JavaScript

If you want to send users to another path in response to a user action (outside of a lifecycle callback), you can do that by using the static Router.go('/to/path') method on the Vaadin.Router class.

You can optionally pass search query string and hash to the method, either as in-app URL string:

... or using an object with named parameters:

NOTE: the same effect can be achieved by dispatching a vaadin-router-go custom event on the window. The target path should be provided as event.detail.pathname, the search and hash strings can be optionally provided with event.detail.search and event.detail.hash properties respectively.

`; } } ================================================ FILE: demo/redirect/snippets/s1.ts ================================================ import { Router } from '@vaadin/router'; // tag::snippet[] Router.go('/to/path?paramName=value#sectionName'); // end::snippet[] ================================================ FILE: demo/redirect/snippets/s2.ts ================================================ import { Router } from '@vaadin/router'; // tag::snippet[] Router.go({ pathname: '/to/path', // optional search: '?paramName=value', // optional hash: '#sectionName', }); // end::snippet[] ================================================ FILE: demo/redirect/snippets/s3.ts ================================================ // tag::snippet[] window.dispatchEvent( new CustomEvent('vaadin-router-go', { detail: { pathname: '/to/path', // optional search query string search: '?paramName=value', // optional hash string hash: '#sectionName', }, }), ); // end::snippet[] export {}; ================================================ FILE: demo/route-actions/d1/iframe.html ================================================ Route Actions
================================================ FILE: demo/route-actions/d1/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import { Router, type RouteContext } from '@vaadin/router'; // tag::snippet[] const urlToNumberOfVisits: Record = {}; async function recordUrlVisit(context: RouteContext) { const visitedPath = context.pathname; // get current path urlToNumberOfVisits[visitedPath] = (urlToNumberOfVisits[visitedPath] ?? 0) + 1; document.getElementById('stats')!.textContent = `Statistics on URL visits: ${JSON.stringify(urlToNumberOfVisits, null, 2)}}`; return await context.next(); // pass to the next route in the list } const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', action: recordUrlVisit, // will be triggered for all children children: [ { path: '/', component: 'x-user-list' }, { path: '/:user', component: 'x-user-profile' }, ], }, ]); // end::snippet[] ================================================ FILE: demo/route-actions/d2/iframe.html ================================================ Route Actions
================================================ FILE: demo/route-actions/d2/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import { Router } from '@vaadin/router'; // tag::snippet[] async function pollBackendForChanges() { return await new Promise((resolve) => { // this can be an async backend call setTimeout(() => resolve(), 1000); }); } const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', action: pollBackendForChanges, // will be triggered for all children children: [ { path: '/', component: 'x-user-list' }, { path: '/:user', component: 'x-user-profile' }, ], }, ]); // end::snippet[] ================================================ FILE: demo/route-actions/d3/iframe.html ================================================ Route Actions
================================================ FILE: demo/route-actions/d3/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-profile.js'; import { Router, type Commands, type RouteContext } from '@vaadin/router'; // tag::snippet[] function redirect(context: RouteContext, commands: Commands) { (context.params.user as string) += ' (redirected)'; return commands.redirect(`/users/:user`); } const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, // current route parameters are automatically transferred to redirect target { path: '/employees/:user', action: redirect }, { path: '/users/:user', component: 'x-user-profile' }, ]); // end::snippet[] ================================================ FILE: demo/route-actions/d4/iframe.html ================================================ Route Actions
================================================ FILE: demo/route-actions/d4/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-profile.js'; import { Router, type Commands, type RouteContext } from '@vaadin/router'; // tag::snippet[] function render(context: RouteContext, commands: Commands) { if (context.params.user === 'admin') { return commands.component('x-user-profile'); } const stubElement = commands.component('h3'); stubElement.innerHTML = 'Access denied'; return stubElement; } const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, // current route parameters are automatically transferred to rendered element { path: '/users/:user', action: render }, ]); // end::snippet[] ================================================ FILE: demo/route-actions/d5/iframe.html ================================================ Route Actions
================================================ FILE: demo/route-actions/d5/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-login-view.js'; import { Router, type Commands, type RouteContext } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', async action(context: RouteContext, commands: Commands) { // Extract the `?view=` parameter value and decide upon it const view = new URLSearchParams(context.search).get('view'); if (view === 'login') { return commands.component('x-login-view'); } else if (view) { // Redirect home for unkown values return commands.redirect('/'); } // Skip to next route if parameter is absent return await context.next(); }, }, // Same path, only matches if the action above skips { path: '/', component: 'x-home-view' }, ]); // end::snippet[] ================================================ FILE: demo/route-actions/index.html ================================================ Route Actions ================================================ FILE: demo/route-actions/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-demo-layout.js'; import '@helpers/vaadin-demo-code-snippet.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import htmlCode1 from './d1/iframe.html?snippet'; import tsCode1 from './d1/script.js?snippet'; import htmlCode2 from './d2/iframe.html?snippet'; import tsCode2 from './d2/script.js?snippet'; import htmlCode3 from './d3/iframe.html?snippet'; import tsCode3 from './d3/script.js?snippet'; import htmlCod4 from './d4/iframe.html?snippet'; import tsCode4 from './d4/script.js?snippet'; import htmlCode5 from './d5/iframe.html?snippet'; import tsCode5 from './d5/script.js?snippet'; import css from '@helpers/page.css?ctr'; import type { CodeSnippet } from '@helpers/vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-route-actions': DemoRouteActions; } } const files1: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode1, title: 'index.html', }, { id: 'ts', code: tsCode1, title: 'script.ts', }, ]; const files2: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode2, title: 'index.html', }, { id: 'ts', code: tsCode2, title: 'script.ts', }, ]; const files3: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode3, title: 'index.html', }, { id: 'ts', code: tsCode3, title: 'script.ts', }, ]; const files4: readonly CodeSnippet[] = [ { id: 'html', code: htmlCod4, title: 'index.html', }, { id: 'ts', code: tsCode4, title: 'script.ts', }, ]; const files5: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode5, title: 'index.html', }, { id: 'ts', code: tsCode5, title: 'script.ts', }, ]; @customElement('vaadin-demo-route-actions') export default class DemoRouteActions extends LitElement { static override styles = [css]; override render(): TemplateResult { return html`

Custom Route Actions

This feature is for advanced use cases. Please make sure to read the documentation carefully.

Route resolution is an async operation started by a navigation event, or by an explicit render() method call. In that process Vaadin Router goes through the routes config and tries to resolve each matching route from the root onwards. The default route resolution rule is to create and return an instance of the route's component (see the API docs for the setRoutes() method for details on other route properties and how they affect the route resolution).

Vaadin Router provides a flexible API to customize the default route resolution rule. Each route may have an action functional property that defines how exactly that route is resolved. An action function can return a result either directly, or within a Promise resolving to the result. If the action result is an HTMLElement instance, a commands.component(name) result, a commands.redirect(path) result, or a context.next() result, the resolution pass, and the returned value is what gets rendered. Otherwise, the resolution process continues to check the other properties of the route and apply the default resolution rules, and then further to check other matching routes.

The action(context, commands) function receives a context parameter with the following properties:

  • context.pathname [string] the pathname being resolved
  • context.search [string] the search query string
  • context.hash [string] the hash string
  • context.params [object] the route parameters
  • context.route [object] the route that is currently being rendered
  • context.next() [function] function for asynchronously getting the next route contents from the resolution chain (if any)

The commands is a helper object with methods to create return values for the action:

  • return commands.redirect('/new/path') create and return a redirect command for the specified path. This command should be returned from the action to perform an actual redirect.
  • return commands.prevent() create and return a prevent command. This command should be returned from the action to instruct router to stop the current navigation and remain at the previous location.
  • return commands.component('tag-name') create and return a new HTMLElement that will be rendered into the router outlet. Using the component command ensures that the created component is initialized as a Vaadin Router view (i.e. the location property is set according to the current router context.
    If an action returns this element, the behavior is the same as for component route property: the action result will be rendered, if the action is in a child route, the result will be rendered as light dom child of the component from a parent route.

This demo shows how to use the custom action property to collect visit statistics for each route.

Async Route Resolution

Since route resolution is async, the action() callback may be async as well and return a promise. One use case for that is to create a custom route action that makes a remote API call to fetch the data necessary to render the route component, before navigating to a route.

Note: If a route has both the component and action properties, action is executed first and if it does not return a result Vaadin.Router proceeds to check the component property.

This demo shows a way to perform async tasks before navigating to any route under /users.

Redirecting from an Action

action() can return a command created using the commands parameter methods to affect the route resolution result. The first demo had demonstrated the context.next() usage, this demo demonstrates using commands.redirect(path) to redirect to any other defined route by using its path. All the parameters in current context will be passed to the redirect target.

Note: If you need only to redirect to another route, defining an action might be an overkill. More convenient way is described in Redirects section.

Returning Custom Element as an Action Result

Another command available to a custom action() is commands.component('tag-name'). It is useful to create a custom element with current context. All the parameters in current context will be passed to the rendered element.

Note: If the only thing your action does is custom element creation, it can be replaced with component property of the route. See Getting Started section for examples.

Routing With Search Query and Hash

Route action function can access context.search and context.hash URL parts, even though they are not involved in matching route paths.

For example, an action can change the route behavior depending on a search parameter, and optionally render, skip to next route or redirect.

`; } } ================================================ FILE: demo/route-parameters/d1/iframe.html ================================================ Route Parameters Home Admin Guest Image 24px blue Image 32px pink Cats
================================================ FILE: demo/route-parameters/d1/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-profile.js'; import '@helpers/x-image-view.js'; import '@helpers/x-knowledge-base.js'; import '@helpers/x-profile-view.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/profile/:user', component: 'x-user-profile' }, { path: '/image/:size/:color?', component: 'x-image-view' }, { path: '/image-:size(\\d+)px', component: 'x-image-view' }, { path: '/kb/:path*', component: 'x-knowledge-base' }, { path: '/(user[s]?)/:id', component: 'x-profile-view' }, ]); // end::snippet[] ================================================ FILE: demo/route-parameters/d2/iframe.html ================================================ Route Parameters Home Project 1 Project 2
================================================ FILE: demo/route-parameters/d2/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import { Router } from '@vaadin/router'; import './x-project-view.js'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/(project[s]?)/:id', component: 'x-project-view' }, ]); // end::snippet[] ================================================ FILE: demo/route-parameters/d2/x-project-view.ts ================================================ import { LitElement, html, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RouterLocation, WebComponentInterface } from '@vaadin/router'; // tag::snippet[] @customElement('x-project-view') export default class ProjectView extends LitElement implements WebComponentInterface { @property({ type: Object }) accessor location: RouterLocation | undefined; override render(): TemplateResult { return html`

Project

ID: ${this.location?.params.id ?? 'unknown'}

/project or /projects: ${this.location?.params[0] ?? 'unknown'}`; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-project-view': ProjectView; } } ================================================ FILE: demo/route-parameters/d3/iframe.html ================================================ Route Parameters Home All Users Kim
================================================ FILE: demo/route-parameters/d3/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users', component: 'x-user-list' }, { path: '/:user', component: 'x-user-profile' }, ]); // end::snippet[] ================================================ FILE: demo/route-parameters/d4/iframe.html ================================================ Route Parameters Home All Users User 42 Guest
================================================ FILE: demo/route-parameters/d4/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-numeric-view.js'; import '@helpers/x-user-not-found-view.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/users/list', component: 'x-user-list' }, { path: '/users/([0-9]+)', component: 'x-user-numeric-view' }, { path: '/users/(.*)', component: 'x-user-not-found-view' }, ]); // end::snippet[] ================================================ FILE: demo/route-parameters/d5/iframe.html ================================================ Route Parameters
================================================ FILE: demo/route-parameters/d5/script.ts ================================================ import '@helpers/iframe.script.js'; import { Router } from '@vaadin/router'; import './x-page-number-view.js'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([{ path: '/', component: 'x-page-number-view' }]); // end::snippet[] ================================================ FILE: demo/route-parameters/d5/x-page-number-view.ts ================================================ /* eslint-disable @typescript-eslint/class-methods-use-this */ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RouterLocation, WebComponentInterface } from '@vaadin/router'; // tag::snippet[] @customElement('x-page-number-view') export default class PageNumberView extends LitElement implements WebComponentInterface { @property({ type: Object }) accessor location: RouterLocation | undefined; override render(): TemplateResult { return html`Page number: ${this.#getPageNumber()}`; } #getPageNumber(): string { return this.location?.searchParams.get('page') ?? 'none'; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-page-number-view': PageNumberView; } } ================================================ FILE: demo/route-parameters/d6/iframe.html ================================================ Route Parameters
================================================ FILE: demo/route-parameters/d6/script.ts ================================================ import '@helpers/iframe.script.js'; import './x-hash-view.js'; import { Router } from '@vaadin/router'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([{ path: '/', component: 'x-hash-view' }]); // end::snippet[] ================================================ FILE: demo/route-parameters/d6/x-hash-view.ts ================================================ import { html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RouterLocation, WebComponentInterface } from '@vaadin/router'; // tag::snippet[] @customElement('x-hash-view') export class HashView extends LitElement implements WebComponentInterface { @property({ type: Object }) accessor location: RouterLocation | undefined; override render(): TemplateResult { return html` Current hash: ${this.location?.hash ?? 'unknown'} `; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-hash-view': HashView; } } ================================================ FILE: demo/route-parameters/index.html ================================================ Route Parameters ================================================ FILE: demo/route-parameters/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-demo-layout.js'; import '@helpers/vaadin-demo-code-snippet.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import htmlCode1 from './d1/iframe.html?snippet'; import tsCode1 from './d1/script.js?snippet'; import htmlCode2 from './d2/iframe.html?snippet'; import tsCode2 from './d2/script.js?snippet'; import projectViewCode from './d2/x-project-view.ts?snippet'; import htmlCode3 from './d3/iframe.html?snippet'; import tsCode3 from './d3/script.js?snippet'; import htmlCode4 from './d4/iframe.html?snippet'; import tsCode4 from './d4/script.js?snippet'; import htmlCode5 from './d5/iframe.html?snippet'; import tsCode5 from './d5/script.js?snippet'; import pageNumberViewCode from './d5/x-page-number-view.ts?snippet'; import htmlCode6 from './d6/iframe.html?snippet'; import tsCode6 from './d6/script.js?snippet'; import hashViewCode from './d6/x-hash-view.ts?snippet'; import css from '@helpers/page.css?ctr'; import type { CodeSnippet } from '@helpers/vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-route-parameters': DemoRouteParameters; } } const files1: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode1, title: 'index.html', }, { id: 'ts', code: tsCode1, title: 'script.ts', }, ]; const files2: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode2, title: 'index.html', }, { id: 'ts', code: tsCode2, title: 'script.ts', }, { id: 'project-view', code: projectViewCode, title: 'x-project-view.ts', }, ]; const files3: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode3, title: 'index.html', }, { id: 'ts', code: tsCode3, title: 'script.ts', }, ]; const files4: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode4, title: 'index.html', }, { id: 'ts', code: tsCode4, title: 'script.ts', }, ]; const files5: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode5, title: 'index.html', }, { id: 'ts', code: tsCode5, title: 'script.ts', }, { id: 'page-number-view', code: pageNumberViewCode, title: 'x-page-number-view.ts', }, ]; const files6: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode6, title: 'index.html', }, { id: 'ts', code: tsCode6, title: 'script.ts', }, { id: 'hash-view', code: hashViewCode, title: 'x-hash-view.ts', }, ]; @customElement('vaadin-demo-route-parameters') export default class DemoRouteParameters extends LitElement { static override styles = [css]; override render(): TemplateResult { return html`

Route Parameters

Route parameters are useful when the same Web Component should be rendered for a number of paths, where a part of the path is static, and another part contains a parameter value. E.g. for both /user/1 and /user/42 paths it's the same Web Component that renders the content, the /user/ part is static, and 1 and 42 are the parameter values.

Route parameters are defined using an express.js-like syntax. The implementation is based on the path-to-regexp library that is commonly used in modern front-end libraries and frameworks. All features are supported:

  • named parameters: /profile/:user
  • optional parameters: /:size/:color?
  • zero-or-more segments: /kb/:path*
  • one-or-more segments: /kb/:path+
  • custom parameter patterns: /image-:size(\d+)px
  • unnamed parameters: /(user[s]?)/:id

Accessing Route Parameters

Route parameters are bound to the location.params property of the route Web Component (location is set on the route components by Vaadin Router).

  • Named parameters are accessible by a string key, e.g. location.params.id or location.params['id']
  • Unnamed parameters are accessible by a numeric index, e.g. location.params[0]

The example below shows how to access route parameters:

Ambiguous Matches

Route matching rules can be ambiguous, so that several routes would match the same path. In that case, the order in which the routes are defined is important. The first route matching the path gets rendered (starting from the top of the list / root of the tree).

The default route matching is exact, i.e. a '/user' route (if it does not have children) matches only the '/user' path, but not '/users' nor '/user/42'. Trailing slashes are not significant in paths, but are significant in routes, i.e. a '/user' route matches both '/user' the '/user/', but a '/user/' route matches only the '/user/' path.

Prefix matching is used for routes with children, or if the route explicitly indicates that trailing content is expected (e.g. a '/users/(*.)' route matches any path starting with '/users/').

Always place more specific routes before less specific:

  • {path: '/user/new', ...} - matches only '/user/new'
  • {path: '/user/:user', ...} - matches '/user/42', but not '/user/42/edit'
  • {path: '/user/(.*)', ...} - matches anything starting with '/user/'

Typed Route Parameters

The route can be configured so that only specific characters are accepted for a parameter value. Other characters would not meet the check and the route resolution would continue to other routes. You only can use unnamed parameters in this case, as it can only be achieved using the custom RegExp. One possible alternative is to use Route Actions and check the context.params.

Search Query Parameters

The search query string (?example) URL part is considered separate from the pathname. Hence, it does not participate in matching the route path, and location.params does not contain search query string parameters.

Use location.search to access the raw search query string. Use location.searchParams to get the URLSearchParams wrapper of the search query string.

Hash String

Likewise with the search query, the hash string (#example) is separate from the pathname as well. Use location.hash to access the hash string in the view component.

`; } } ================================================ FILE: demo/tsconfig.json ================================================ { "extends": ["../tsconfig.json"], "compilerOptions": { "paths": { "@helpers/*": ["./@helpers/*"], "@vaadin/router": ["../src/index.ts"] } }, "include": ["./**/*", "./*"] } ================================================ FILE: demo/types.t.ts ================================================ /* eslint-disable import/unambiguous */ // eslint-disable-next-line @typescript-eslint/triple-slash-reference /// declare module '*.css?ctr' { const css: CSSStyleSheet; export default css; } declare module '*?snippet' { import type { TemplateResult } from 'lit'; const snippets: [code: string, full: TemplateResult, ...rest: TemplateResult[]]; export default snippets; } ================================================ FILE: demo/url-generation/d1/iframe.html ================================================ URL Generation
================================================ FILE: demo/url-generation/d1/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import { Router } from '@vaadin/router'; import './x-main-layout.js'; // tag::snippet[] export type RouteExtension = Readonly<{ router: Router; }>; export const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-main-layout', router, children: [ { name: 'home', path: '/', component: 'x-home-view', router }, { path: '/users', component: 'x-user-list', router }, { path: '/users/:user', component: 'x-user-profile', router }, ], }, ]); // end::snippet[] ================================================ FILE: demo/url-generation/d1/x-main-layout.ts ================================================ import { html, LitElement, nothing, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RouteExtension } from './script.js'; import type { RouterLocation, WebComponentInterface } from '@vaadin/router'; // tag::snippet[] @customElement('x-main-layout') export default class MainLayout extends LitElement implements WebComponentInterface { @property({ attribute: false }) accessor location: RouterLocation | undefined; override render(): TemplateResult | typeof nothing { return this.location?.route ? html` Home Users My profile ` : nothing; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-main-layout': MainLayout; } } ================================================ FILE: demo/url-generation/d2/iframe.html ================================================ URL Generation
================================================ FILE: demo/url-generation/d2/script.ts ================================================ import '@helpers/iframe.script.js'; import '@helpers/x-home-view.js'; import '@helpers/x-user-list.js'; import '@helpers/x-user-profile.js'; import { Router } from '@vaadin/router'; import './x-main-layout.js'; // tag::snippet[] export type RouteExtension = Readonly<{ router: Router; }>; export const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', component: 'x-main-layout', router, children: [ { path: '/', component: 'x-home-view', router }, { path: '/users', component: 'x-user-list', router }, { path: '/users/:user', component: 'x-user-profile', router }, ], }, ]); // end::snippet[] ================================================ FILE: demo/url-generation/d2/x-main-layout.ts ================================================ import { html, LitElement, nothing, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RouteExtension } from './script.js'; import type { RouterLocation, WebComponentInterface } from '@vaadin/router'; // tag::snippet[] @customElement('x-main-layout') export default class MainLayout extends LitElement implements WebComponentInterface { @property({ attribute: false }) accessor location: RouterLocation | undefined; override render(): TemplateResult | typeof nothing { return this.location?.route ? html` Home Users My profile ` : nothing; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-main-layout': MainLayout; } } ================================================ FILE: demo/url-generation/d3/iframe.html ================================================ URL Generation
================================================ FILE: demo/url-generation/d3/script.ts ================================================ import '@helpers/iframe.script.js'; import { Router } from '@vaadin/router'; import './x-user-layout-d3.js'; // tag::snippet[] export type RouteExtension = Readonly<{ router: Router; }>; export const router = new Router(document.getElementById('outlet')); await router.setRoutes([ { path: '/', redirect: '/users/me', router }, { path: '/users/:user', component: 'x-user-layout-d3', router }, ]); // end::snippet[] ================================================ FILE: demo/url-generation/d3/x-user-layout-d3.ts ================================================ import '@helpers/x-user-profile.js'; import { html, LitElement, nothing, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import type { RouteExtension } from './script.js'; import type { RouterLocation, WebComponentInterface } from '@vaadin/router'; // tag::snippet[] @customElement('x-user-layout-d3') export default class UserLayoutD3 extends LitElement implements WebComponentInterface { @property({ attribute: false }) accessor location: RouterLocation | undefined; override render(): TemplateResult | typeof nothing { return this.location?.route ? html` My profile Admin profile ` : nothing; } #getUrlForUser(user: string): string | undefined { return this.location?.route?.router.urlForPath('/users/:user', { user }); } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-user-layout-d3': UserLayoutD3; } } ================================================ FILE: demo/url-generation/d4/iframe.html ================================================ URL Generation
================================================ FILE: demo/url-generation/d4/script.ts ================================================ import '@helpers/iframe.script.js'; import { Router } from '@vaadin/router'; import './x-user-layout-d4.js'; history.pushState(null, '', '/ui/'); // tag::snippet[] export type RouteExtension = Readonly<{ router: Router; }>; export const router = new Router(document.getElementById('outlet'), { baseUrl: '/ui/' }); await router.setRoutes([ { path: '/', redirect: '/users/me', router }, { name: 'users', path: '/users/:user', component: 'x-user-layout-d4', router }, ]); // end::snippet[] ================================================ FILE: demo/url-generation/d4/x-user-layout-d4.ts ================================================ import '@helpers/x-user-profile.js'; import { html, LitElement, nothing, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { RouteExtension } from './script.js'; import type { RouterLocation, WebComponentInterface } from '@vaadin/router'; // tag::snippet[] @customElement('x-user-layout-d4') export default class UserLayoutD4 extends LitElement implements WebComponentInterface { @property({ attribute: false }) accessor location: RouterLocation | undefined; override render(): TemplateResult | typeof nothing { return this.location?.route ? html` My profile Manager profile Admin profile ` : nothing; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-user-layout-d4': UserLayoutD4; } } ================================================ FILE: demo/url-generation/d5/iframe.html ================================================ URL Generation
================================================ FILE: demo/url-generation/d5/script.ts ================================================ import '@helpers/iframe.script.js'; import { Router } from '@vaadin/router'; import './x-pages-menu.js'; // tag::snippet[] const router = new Router(document.getElementById('outlet')); await router.setRoutes([{ path: '/', component: 'x-pages-menu' }]); // end::snippet[] ================================================ FILE: demo/url-generation/d5/x-pages-menu.ts ================================================ import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { map } from 'lit/directives/map.js'; import type { RouterLocation } from '@vaadin/router'; // tag::snippet[] const pages = [1, 2, 3, 4, 5]; function urlForPageNumber(location: RouterLocation, pageNumber: number) { const query = new URLSearchParams({ page: String(pageNumber) }).toString(); return `${location.getUrl()}?${query}`; } function urlForSection(location: RouterLocation, section: string) { return `${location.getUrl()}#${section}`; } @customElement('x-pages-menu') export class PagesMenu extends LitElement { static override styles = css` nav { display: flex; gap: 0.5rem; } `; @property({ attribute: false }) accessor location: RouterLocation | undefined; override render(): TemplateResult | typeof nothing { return this.location ? html` ` : nothing; } } // end::snippet[] declare global { interface HTMLElementTagNameMap { 'x-pages-menu': PagesMenu; } } ================================================ FILE: demo/url-generation/index.html ================================================ URL Generation ================================================ FILE: demo/url-generation/index.ts ================================================ /* eslint-disable import/no-duplicates, import/default */ import '@helpers/common.js'; import '@helpers/vaadin-demo-layout.js'; import '@helpers/vaadin-demo-code-snippet.js'; import '@helpers/vaadin-presentation.js'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import htmlCode1 from './d1/iframe.html?snippet'; import tsCode1 from './d1/script.js?snippet'; import mainLayoutCode1 from './d1/x-main-layout.ts?snippet'; import htmlCode2 from './d2/iframe.html?snippet'; import tsCode2 from './d2/script.js?snippet'; import mainLayoutCode2 from './d2/x-main-layout.ts?snippet'; import htmlCode3 from './d3/iframe.html?snippet'; import tsCode3 from './d3/script.js?snippet'; import userLayoutD3Code from './d3/x-user-layout-d3.ts?snippet'; import htmlCode4 from './d4/iframe.html?snippet'; import tsCode4 from './d4/script.js?snippet'; import userLayoutD4Code from './d4/x-user-layout-d4.ts?snippet'; import htmlCode5 from './d5/iframe.html?snippet'; import tsCode5 from './d5/script.js?snippet'; import pagesMenuCode from './d5/x-pages-menu.ts?snippet'; import css from '@helpers/page.css?ctr'; import type { CodeSnippet } from '@helpers/vaadin-demo-code-snippet.js'; declare global { interface HTMLElementTagNameMap { 'vaadin-demo-url-generation': DemoUrlGeneration; } } const files1: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode1, title: 'index.html', }, { id: 'ts', code: tsCode1, title: 'script.ts', }, { id: 'main-layout', code: mainLayoutCode1, title: 'x-main-layout.ts', }, ]; const files2: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode2, title: 'index.html', }, { id: 'ts', code: tsCode2, title: 'script.ts', }, { id: 'main-layout', code: mainLayoutCode2, title: 'x-main-layout.ts', }, ]; const files3: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode3, title: 'index.html', }, { id: 'ts', code: tsCode3, title: 'script.ts', }, { id: 'user-layout', code: userLayoutD3Code, title: 'x-user-layout.ts', }, ]; const files4: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode4, title: 'index.html', }, { id: 'ts', code: tsCode4, title: 'script.ts', }, { id: 'user-layout', code: userLayoutD4Code, title: 'x-user-layout.ts', }, ]; const files5: readonly CodeSnippet[] = [ { id: 'html', code: htmlCode5, title: 'index.html', }, { id: 'ts', code: tsCode5, title: 'script.ts', }, { id: 'pages-menu', code: pagesMenuCode, title: 'x-pages-menu.ts', }, ]; @customElement('vaadin-demo-url-generation') export default class DemoUrlGeneration extends LitElement { static override styles = [css]; override render(): TemplateResult { return html`

Named routes and the router.urlForName method

Vaadin Router supports referring to routes using string names. You can assign a name to a route using the name property of a route object, then generate URLs for that route using the router.urlForName(name, parameters) helper instance method.

Arguments:

  • name — the route name
  • parameters — optional object with parameters for substitution in the route path

If the component property is specified on the route object, the name property could be omitted. In that case, the component name could be used in the router.urlForName().

The router.urlForPath method

router.urlForPath(path, parameters) is a helper method that generates a URL for the given route path, optionally performing substitution of parameters.

Arguments:

  • path — a string route path defined in express.js syntax
  • parameters — optional object with parameters for path substitution

The location.getUrl method

location.getUrl(params) is a method that returns a URL corresponding to the location. When given the params argument, it does parameter substitution in the location’s chain of routes.

Arguments:

  • params — optional object with parameters to override the location parameters

Base URL in URL generation

When base URL is set, the URL generation helpers return absolute pathnames, including the base.

Generating URLs with search query parameters and hash string

At the moment, Vaadin Router does not provide URL generation APIs for appending search query parameters or hash strings to the generated URLs. However, you could append those with string concatenation.

For serialising parameters into a query string, use the native URLSearchParams API.

`; } } ================================================ FILE: demo/vite.config.ts ================================================ import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { glob } from 'glob'; import { mergeConfig } from 'vite'; import { codeSnippetPlugin } from '../scripts/codeSnippet.js'; import viteConfig from '../vite.config.js'; const root = new URL('./', import.meta.url); function convertToId(str: string) { return str.replace(/[-/]./gu, (x) => x[1].toUpperCase()); } const dirs = Object.fromEntries( await glob('./**/{index,iframe}.html', { cwd: root }).then((files) => files .filter((file) => !file.startsWith('@') && file !== 'index.html') .map((name) => [convertToId(dirname(name)), fileURLToPath(new URL(name, root))]), ), ); export default mergeConfig(viteConfig, { base: './', build: { outDir: fileURLToPath(new URL('../.docs', root)), rollupOptions: { input: { main: fileURLToPath(new URL('./index.html', root)), ...dirs, }, }, }, resolve: { alias: { '@helpers/': fileURLToPath(new URL('./@helpers/', root)), '@vaadin/router': fileURLToPath(new URL('../src/index.js', root)), }, }, plugins: [codeSnippetPlugin()], root: fileURLToPath(root), }); ================================================ FILE: index.html ================================================ ================================================ FILE: karma.config.cjs ================================================ const karmaChromeLauncher = require('karma-chrome-launcher'); const karmaCoverage = require('karma-coverage'); const karmaMocha = require('karma-mocha'); const karmaVite = require('karma-vite'); const karmaSpecReporter = require('karma-spec-reporter'); const isCI = !!process.env.CI; const watch = !!process.argv.find((arg) => arg.includes('watch')) && !isCI; const coverage = !!process.argv.find((arg) => arg.includes('--coverage')); module.exports = (config) => { config.set({ basePath: '', plugins: [karmaMocha, karmaChromeLauncher, karmaVite, karmaCoverage, karmaSpecReporter], browsers: ['ChromeHeadlessNoSandbox'], // you can define custom flags customLaunchers: { ChromeHeadlessNoSandbox: { base: 'ChromeHeadless', flags: ['--no-sandbox', '--disable-setuid-sandbox', isCI ? undefined : '--disable-dev-shm-usage'].filter( Boolean, ), }, }, captureTimeout: 60000, // it was already there browserDisconnectTimeout: 10000, browserDisconnectTolerance: 1, browserNoActivityTimeout: 60000, //by default 10000 frameworks: ['vite', 'mocha'], files: [ { pattern: 'test/**/*.spec.ts', type: 'module', watched: false, served: false, }, ], reporters: ['spec', coverage && 'coverage'].filter(Boolean), autoWatch: watch, singleRun: !watch, coverageReporter: { dir: '.coverage/', reporters: [!isCI && { type: 'html', subdir: 'html' }, { type: 'lcovonly', subdir: '.' }].filter(Boolean), }, vite: { autoInit: true, }, }); }; ================================================ FILE: package.json ================================================ { "name": "@vaadin/router", "version": "2.0.1", "description": "Small and powerful client-side router for Web Components. Framework-agnostic.", "main": "dist/index.js", "module": "dist/index.js", "type": "module", "repository": "vaadin/vaadin-router", "keywords": [ "Vaadin", "vaadin-router", "router", "client-side", "web-components" ], "author": "Vaadin Ltd", "license": "Apache-2.0", "bugs": { "url": "https://github.com/vaadin/vaadin-router/issues" }, "homepage": "https://vaadin.com", "files": [ "dist" ], "scripts": { "clean:build": "git clean -fx . -e .vite -e node_modules -e .idea -e .vscode", "install:dependencies": "bower install", "lint": "concurrently npm:lint:*", "lint:js": "eslint --ext .html,.js src test demo *.js *.html", "build": "concurrently npm:build:*", "build:esbuild": "tsx scripts/build.ts", "build:copy-dts": "tsx scripts/copy-dts.ts", "build:tsc": "tsc --emitDeclarationOnly -p tsconfig.build.json", "start": "npm run docs && cd demo && vite preview", "test": "karma start karma.config.cjs", "test:watch": "npm run test -- --watch", "test:coverage": "npm run test -- --coverage", "docs": "npm run docs:demo && npm run docs:typedoc", "docs:typedoc": "typedoc", "docs:demo": "cd demo && vite build --emptyOutDir", "demo:dev": "cd demo && vite dev", "browserslist": "browserslist && browserslist --coverage", "prepack": "npm run clean:build", "prepare": "npm run build", "typecheck": "tsc --noEmit" }, "browserslist": [ "last 1 Chrome major versions", "last 1 Firefox major versions", "Firefox ESR", "last 1 Edge major versions", "Safari >= 13", "iOS >= 13", "last 1 ChromeAndroid major versions" ], "dependencies": { "@vaadin/vaadin-usage-statistics": "^2.1.3", "path-to-regexp": "^6.3.0", "type-fest": "^5.2.0" }, "devDependencies": { "@esm-bundle/chai": "^4.3.4-fix.0", "@lit-labs/preact-signals": "^1.0.3", "@preact/signals-core": "^1.12.1", "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@types/chai-as-promised": "^8.0.2", "@types/chai-dom": "^1.11.3", "@types/glob": "^9.0.0", "@types/karma": "^6.3.9", "@types/karma-chrome-launcher": "^3.1.4", "@types/karma-mocha": "^1.3.4", "@types/mocha": "^10.0.10", "@types/postcss-import": "^14.0.3", "@types/sinon": "^17.0.4", "@types/sinon-chai": "^4.0.0", "@vaadin/accordion": "^24.9.5", "@vaadin/app-layout": "^24.9.5", "@vaadin/button": "^24.9.5", "@vaadin/dialog": "^24.9.5", "@vaadin/icon": "^24.9.5", "@vaadin/icons": "^24.9.5", "@vaadin/scroller": "^24.9.5", "@vaadin/side-nav": "^24.9.5", "@vaadin/tabs": "^24.9.5", "@vaadin/tabsheet": "^24.9.5", "@vaadin/text-field": "^24.9.5", "@vaadin/tooltip": "^24.9.5", "@vaadin/vaadin-lumo-styles": "^24.9.5", "bower": "^1.8.14", "browserslist": "^4.27.0", "chai-as-promised": "^8.0.2", "chai-dom": "^1.12.1", "concurrently": "^9.2.1", "cssnano": "^7.1.2", "esbuild": "^0.27.0", "eslint": "^9.39.1", "eslint-config-vaadin": "^1.0.0-beta.3", "eslint-plugin-html": "^8.1.3", "eslint-plugin-tsdoc": "^0.4.0", "fs-extra": "^11.3.2", "glob": "^11.0.3", "highlight.js": "^11.11.1", "karma": "^6.4.4", "karma-chrome-launcher": "^3.2.0", "karma-coverage": "^2.2.1", "karma-iframes": "^1.3.1", "karma-mocha": "^2.0.1", "karma-parallel": "^0.3.1", "karma-spec-reporter": "^0.0.36", "karma-vite": "^1.0.5", "lit": "^3.3.1", "magic-string": "^0.30.21", "mocha": "^11.7.5", "node-html-parser": "^7.0.1", "postcss": "^8.5.6", "postcss-import": "^16.1.1", "postcss-nested-import": "^1.3.0", "prettier": "^3.6.2", "regexp.escape": "^2.0.1", "sinon": "^21.0.0", "sinon-chai": "^4.0.1", "tsx": "^4.20.6", "typedoc": "^0.28.14", "typedoc-plugin-missing-exports": "^4.1.2", "typescript": "^5.9.3", "typescript-eslint": "^8.46.3", "vite": "7.2.2", "yargs": "^18.0.0" }, "overrides": { "karma-vite": { "vite": "$vite" } } } ================================================ FILE: polymer.json ================================================ { "entrypoint": "bower_components/vaadin-router/index.html", "shell": "bower_components/iron-component-page/iron-component-page.html", "sources": [ "bower_components/vaadin-router/index.html", "bower_components/iron-component-page/iron-component-page.html" ], "extraDependencies": [ "bower_components/webcomponentsjs/*.js", "bower_components/webcomponentsjs/*.map", "bower_components/vaadin-router/analysis.json" ], "builds": [ { "preset": "es5-bundled", "addServiceWorker": false } ] } ================================================ FILE: scripts/build.ts ================================================ import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { build } from 'esbuild'; import { glob } from 'glob'; import type { PackageJson } from 'type-fest'; const scriptsDir = new URL('./', import.meta.url); const root = new URL('../', import.meta.url); const [packageJson, entryPoints] = await Promise.all([ readFile(new URL('package.json', root), 'utf8').then(JSON.parse) as Promise, glob('src/**/*.{ts,tsx}', { ignore: ['**/*.t.ts'] }), ]); await build({ define: { __NAME__: `'${packageJson.name ?? '@hilla/unknown'}'`, __VERSION__: `'${packageJson.version ?? '0.0.0'}'`, }, entryPoints, format: 'esm', // Adds a __REGISTER__ function definition everywhere in the built code where // the call for that function exists. inject: [fileURLToPath(new URL('./register.js', scriptsDir))], outdir: 'dist', sourcemap: 'linked', sourcesContent: true, tsconfig: fileURLToPath(new URL('./tsconfig.build.json', root)), }); ================================================ FILE: scripts/codeSnippet.ts ================================================ import 'regexp.escape/auto'; import { extname } from 'node:path'; import hljs from 'highlight.js/lib/core'; import cssLang from 'highlight.js/lib/languages/css'; import javascriptLang from 'highlight.js/lib/languages/javascript'; import typescriptLang from 'highlight.js/lib/languages/typescript'; import xmlLang from 'highlight.js/lib/languages/xml'; import * as prettier from 'prettier'; import type { Plugin } from 'vite'; hljs.registerLanguage('javascript', javascriptLang); hljs.registerLanguage('css', cssLang); hljs.registerLanguage('typescript', typescriptLang); hljs.registerLanguage('xml', xmlLang); function langFromExt(ext: string) { switch (ext) { case 'ts': return 'typescript'; case 'html': return 'xml'; default: return ext; } } type SnippetPatternKey = keyof typeof snippetPattern; const snippetPattern = { html: /([\s\S]*?)/gmu, ts: /\/\/ tag::snippet\[\]([\s\S]*?)\/\/ end::snippet\[\]/gmu, css: /\/\* tag::snippet\[\] \*\/([\s\S]*?)\/\* end::snippet\[\] \*\//gmu, } as const; const patternsToRemove = [/^\s*(?:\/\/|\/\*) eslint-disable.*$/gimu]; function removePatterns(code: string) { return patternsToRemove.reduce((acc, pattern) => acc.replace(pattern, ''), code); } function extractSnippets(code: string, language: SnippetPatternKey) { const result: string[] = []; const pattern = snippetPattern[language]; let match; while ((match = pattern.exec(code)) != null) { result.push(match[1].trim()); } if (result.length === 0) { result.push(code); } return result; } function escapeString(str: string) { return str.replaceAll(/(`|\$|\{\})/gu, '\\$1'); } export function codeSnippetPlugin(): Plugin { return { name: 'code-snippet', enforce: 'pre', async resolveId(id, importer) { if (id.includes('?')) { const resolved = await this.resolve(id, importer); if (resolved) { const search = resolved.id.substring(resolved.id.indexOf('?')); const purePath = resolved.id.substring(0, resolved.id.length - search.length); const params = new URLSearchParams(search); if (params.has('snippet') && purePath.endsWith('.css')) { params.append('raw', ''); return { ...resolved, id: `${purePath}?${params.toString()}`, }; } return resolved; } } return null; }, async transform(code, id) { if (id.includes('?')) { const search = id.substring(id.indexOf('?')); const params = new URLSearchParams(search); if (params.has('snippet')) { const purePath = id.substring(0, id.length - search.length); const ext = extname(purePath).substring(1); if (ext === 'ts' || ext === 'html' || ext === 'css') { let snippets = extractSnippets(removePatterns(code), ext); snippets = [code, ...snippets]; snippets = await Promise.all( snippets.map( async (snippet) => await prettier.format(snippet, { parser: ext === 'ts' ? 'typescript' : ext, linewidth: 80, singleQuote: true, trailingComma: 'all', }), ), ); snippets = snippets.map((snippet) => hljs.highlight(snippet, { language: langFromExt(ext) }).value); return { code: `import { html } from 'lit'; export default [\`${escapeString(code)}\`,${snippets .map((snippet) => `html\`${escapeString(snippet)}\``) .join(',')}];`, map: null, }; } } } return null; }, }; } ================================================ FILE: scripts/constructCss.ts ================================================ import { readFile } from 'node:fs/promises'; import cssnanoPlugin from 'cssnano'; import postcss from 'postcss'; import nestedImport from 'postcss-nested-import'; import type { Plugin } from 'vite'; const cssTransformer = postcss([ nestedImport(), cssnanoPlugin({ preset: 'default', }), ]); export default function constructCss(): Plugin { const styles = new Map(); return { enforce: 'post', name: 'vite-construct-css', async load(id) { if (id.endsWith('.css?ctr')) { const content = await readFile(id.substring(0, id.length - 4), 'utf8'); styles.set(id, content); return { code: '', }; } return undefined; }, async transform(_, id) { if (styles.has(id)) { const css = styles.get(id); const { content } = await cssTransformer.process(css, { from: id, }); return { code: `const css = new CSSStyleSheet(); css.replaceSync(${JSON.stringify(content)}); export default css;`, }; } return undefined; }, }; } ================================================ FILE: scripts/copy-dts.ts ================================================ import { constants, copyFile, mkdir } from 'node:fs/promises'; import { glob } from 'glob'; const root = new URL('../', import.meta.url); const src = new URL('./src/', root); const dist = new URL('./dist/', root); await mkdir(dist, { recursive: true }); const files = await glob(['**/*.d.ts'], { cwd: src }); await Promise.all( files.map(async (f) => { const file = new URL(f, dist); await mkdir(new URL('./', file), { recursive: true }); return await copyFile(new URL(f, src), file, constants.COPYFILE_FICLONE); }), ); ================================================ FILE: scripts/loadRegisterJs.ts ================================================ import { readFile } from 'node:fs/promises'; import MagicString from 'magic-string'; import type { Plugin } from 'vite'; const scripts = new URL('./', import.meta.url); // This plugin adds "__REGISTER__()" function definition everywhere where it finds // the call for that function. It is necessary for a correct code for tests. export default function loadRegisterJs(): Plugin { return { enforce: 'pre', name: 'vite-hilla-register', async transform(code) { if (code.includes('__REGISTER__()') && !code.includes('function __REGISTER__')) { const registerCode = await readFile(new URL('register.js', scripts), 'utf8').then((c) => c.replace('export', ''), ); const _code: MagicString = new MagicString(code); _code.prepend(registerCode); return { code: _code.toString(), map: _code.generateMap(), }; } return null; }, }; } ================================================ FILE: scripts/register.js ================================================ export function __REGISTER__(feature, vaadinObj = (window.Vaadin ??= {})) { vaadinObj.registrations ??= []; vaadinObj.registrations.push({ is: feature ? `${__NAME__}/${feature}` : __NAME__, version: __VERSION__, }); } ================================================ FILE: scripts/resolveHTMLImports.ts ================================================ import { fileURLToPath, pathToFileURL } from 'node:url'; import type { Plugin } from 'vite'; const searchParamsPattern = /\?.*$/iu; export function resolveHTMLImports(): Plugin { return { enforce: 'pre', name: 'resolve-html-imports', resolveId(id, importer) { const _importer = importer ? pathToFileURL(importer.replace(searchParamsPattern, '')) : null; if ( _importer?.pathname.endsWith('.html') && !_importer.pathname.endsWith('index.html') && !_importer.pathname.includes('node_modules') && id.endsWith('.js') ) { return fileURLToPath(new URL(id.replace('.js', '.ts'), _importer as URL)); } return null; }, }; } ================================================ FILE: scripts/types.d.ts ================================================ /* eslint-disable import/unambiguous */ declare module 'regexp.escape/auto'; interface RegExpConstructor { escape(str: string): string; } declare module 'postcss-nested-import' { import type { Processor } from 'postcss'; function plugin(): Processor; export = plugin; } ================================================ FILE: src/index.ts ================================================ export * from './router.js'; export { DEFAULT_TRIGGERS } from './triggers/navigation.js'; export type * from './types.t.js'; export type * from './v1-compat.t.js'; export { processNewChildren, amend, maybeCall, renderElement, createRedirect, createLocation, getMatchedPath, getPathnameForRouter, copyContextWithoutNext, } from './utils.js'; ================================================ FILE: src/mod.t.ts ================================================ // eslint-disable-next-line import/unambiguous declare module '@vaadin/vaadin-usage-statistics/vaadin-usage-statistics.js' { // eslint-disable-next-line import/prefer-default-export export function usageStatistics(): void; } ================================================ FILE: src/resolver/LICENSE.txt ================================================ The MIT License Copyright (c) 2015-present Kriasoft. 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. ================================================ FILE: src/resolver/generateUrls.ts ================================================ /** * Universal Router (https://www.kriasoft.com/universal-router/) * * Copyright (c) 2015-present Kriasoft. * * This source code is licensed under the MIT license found in the * LICENSE.txt file in the root directory of this source tree. */ import { parse, type ParseOptions, type Token, tokensToFunction, type TokensToFunctionOptions } from 'path-to-regexp'; import type { EmptyObject, Writable } from 'type-fest'; import Resolver from './resolver.js'; import type { ChildrenCallback, IndexedParams, Params, Route } from './types.t.js'; import { getRoutePath, isString } from './utils.js'; export type UrlParams = Readonly | number | string>>; function cacheRoutes( routesByName: Map>>, route: Route, routes?: ReadonlyArray> | ChildrenCallback, cacheKeyProvider?: (route: Route) => string | undefined, ): void { const name = route.name ?? cacheKeyProvider?.(route); if (name) { if (routesByName.has(name)) { routesByName.get(name)?.push(route); } else { routesByName.set(name, [route]); } } if (Array.isArray(routes)) { for (const childRoute of routes) { childRoute.parent = route; cacheRoutes(routesByName, childRoute, childRoute.__children ?? childRoute.children, cacheKeyProvider); } } } function getRouteByName( routesByName: Map>>, routeName: string, ): Route | undefined { const routes = routesByName.get(routeName); if (routes) { if (routes.length > 1) { throw new Error(`Duplicate route with name "${routeName}".` + ` Try seting unique 'name' route properties.`); } return routes[0]; } return undefined; } export type StringifyQueryParams = (params: UrlParams) => string; export type GenerateUrlOptions = ParseOptions & Readonly<{ /** * Add a query string to generated url based on unknown route params. */ stringifyQueryParams?: StringifyQueryParams; /** * Generates a unique route name based on all parent routes with the specified separator. */ uniqueRouteNameSep?: string; cacheKeyProvider?(route: Route): string | undefined; }> & TokensToFunctionOptions; type RouteCacheRecord = Readonly<{ keys: Record; tokens: Token[]; }>; export type UrlGenerator = (routeName: string, params?: Params) => string; function generateUrls( resolver: Resolver, options: GenerateUrlOptions = {}, ): UrlGenerator { if (!(resolver instanceof Resolver)) { throw new TypeError('An instance of Resolver is expected'); } const cache = new Map(); const routesByName = new Map>>(); return (routeName, params) => { let route = getRouteByName(routesByName, routeName); if (!route) { routesByName.clear(); // clear cache cacheRoutes(routesByName, resolver.root, resolver.root.__children, options.cacheKeyProvider); route = getRouteByName(routesByName, routeName); if (!route) { throw new Error(`Route "${routeName}" not found`); } } let cached: RouteCacheRecord | undefined = route.fullPath ? cache.get(route.fullPath) : undefined; if (!cached) { let fullPath = getRoutePath(route); let rt = route.parent; while (rt) { const path = getRoutePath(rt); if (path) { fullPath = `${path.replace(/\/$/u, '')}/${fullPath.replace(/^\//u, '')}`; } rt = rt.parent; } const tokens = parse(fullPath); const keys: Record = Object.create(null); for (const item of tokens) { if (!isString(item)) { keys[item.name] = true; } } cached = { keys, tokens }; cache.set(fullPath, cached); route.fullPath = fullPath; } const toPath = tokensToFunction(cached.tokens, { encode: encodeURIComponent, ...options }); let url = toPath(params) || '/'; if (options.stringifyQueryParams && params) { const queryParams: Writable = {}; for (const [key, value] of Object.entries(params)) { if (!(key in cached.keys) && value) { queryParams[key] = value; } } const query = options.stringifyQueryParams(queryParams as UrlParams); if (query) { url += query.startsWith('?') ? query : `?${query}`; } } return url; }; } export default generateUrls; ================================================ FILE: src/resolver/matchPath.ts ================================================ /** * Universal Router (https://www.kriasoft.com/universal-router/) * * Copyright (c) 2015-present Kriasoft. * * This source code is licensed under the MIT license found in the * LICENSE.txt file in the root directory of this source tree. */ import { type Key, pathToRegexp } from 'path-to-regexp'; import type { Writable } from 'type-fest'; import type { IndexedParams } from '../types.t.js'; import { resolvePath } from './utils.js'; export interface RegExpExecOptArray extends ReadonlyArray { 0: string; index: number; input: string; } type Matcher = Readonly<{ keys: readonly Key[]; pattern: RegExp; }>; export type Match = Readonly<{ keys: readonly Key[]; params: IndexedParams; path: string; }>; const cache = new Map(); // see https://github.com/pillarjs/path-to-regexp/issues/148 cache.set('|false', { keys: [], pattern: /(?:)/u, }); function decodeParam(val: string): string { try { return decodeURIComponent(val); } catch { return val; } } // eslint-disable-next-line @typescript-eslint/max-params function matchPath( routePath: string, path?: string[] | string, exact: boolean = false, parentKeys: readonly Key[] = [], parentParams?: IndexedParams, ): Match | null { const cacheKey = `${routePath}|${String(exact)}`; const _path = resolvePath(path); let regexp = cache.get(cacheKey); if (!regexp) { const keys: Key[] = []; regexp = { keys, pattern: pathToRegexp(routePath, keys, { end: exact, strict: routePath === '', }), }; cache.set(cacheKey, regexp); } const m: RegExpExecOptArray | null = regexp.pattern.exec(_path); if (!m) { return null; } const params: Writable = { ...parentParams }; for (let i = 1; i < m.length; i++) { const key = regexp.keys[i - 1]; const prop = key.name; const value = m[i]; if (value !== undefined || !Object.prototype.hasOwnProperty.call(params, prop)) { if (key.modifier === '+' || key.modifier === '*') { // by default, as of path-to-regexp 6.0.0, the default delimiters // are `/`, `#` and `?`. params[prop] = value ? value.split(/[/?#]/u).map(decodeParam) : []; } else { params[prop] = value ? decodeParam(value) : value!; } } } return { keys: [...parentKeys, ...regexp.keys], params, path: m[0], }; } export default matchPath; ================================================ FILE: src/resolver/matchRoute.ts ================================================ /** * Universal Router (https://www.kriasoft.com/universal-router/) * * Copyright (c) 2015-present Kriasoft. * * This source code is licensed under the MIT license found in the * LICENSE.txt file in the root directory of this source tree. */ import type { Key } from 'path-to-regexp'; import matchPath, { type Match } from './matchPath.js'; import type { IndexedParams, Route } from './types.t.js'; import { getRoutePath, unwrapChildren } from './utils.js'; export type MatchWithRoute = Match & Readonly<{ route: Route; }>; type RouteMatchIterator = Iterator< MatchWithRoute, undefined, Route | undefined >; /** * Traverses the routes tree and matches its nodes to the given pathname from * the root down to the leaves. Each match consumes a part of the pathname and * the matching process continues for as long as there is a matching child * route for the remaining part of the pathname. * * The returned value is a lazily evaluated iterator. * * The leading "/" in a route path matters only for the root of the routes * tree (or if all parent routes are ""). In all other cases a leading "/" in * a child route path has no significance. * * The trailing "/" in a _route path_ matters only for the leaves of the * routes tree. A leaf route with a trailing "/" matches only a pathname that * also has a trailing "/". * * The trailing "/" in a route path does not affect matching of child routes * in any way. * * The trailing "/" in a _pathname_ generally does not matter (except for * the case of leaf nodes described above). * * The "" and "/" routes have special treatment: * 1. as a single route * the "" and "/" routes match only the "" and "/" pathnames respectively * 2. as a parent in the routes tree * the "" route matches any pathname without consuming any part of it * the "/" route matches any absolute pathname consuming its leading "/" * 3. as a leaf in the routes tree * the "" and "/" routes match only if the entire pathname is consumed by * the parent routes chain. In this case "" and "/" are equivalent. * 4. several directly nested "" or "/" routes * - directly nested "" or "/" routes are 'squashed' (i.e. nesting two * "/" routes does not require a double "/" in the pathname to match) * - if there are only "" in the parent routes chain, no part of the * pathname is consumed, and the leading "/" in the child routes' paths * remains significant * * Side effect: * - the routes tree `{ path: '' }` matches only the '' pathname * - the routes tree `{ path: '', children: [ { path: '' } ] }` matches any * pathname (for the tree root) * * Prefix matching can be enabled also by `children: true`. */ // eslint-disable-next-line @typescript-eslint/max-params function matchRoute( route: Route, pathname: string, ignoreLeadingSlash?: boolean, parentKeys?: readonly Key[], parentParams?: IndexedParams, ): Iterator, undefined, Route | undefined> { let match: Match | null; let childMatches: RouteMatchIterator | null; let childIndex = 0; let routepath = getRoutePath(route); if (routepath.startsWith('/')) { if (ignoreLeadingSlash) { routepath = routepath.substring(1); } // eslint-disable-next-line no-param-reassign ignoreLeadingSlash = true; } return { next(routeToSkip?: Route): IteratorResult, undefined> { if (route === routeToSkip) { return { done: true, value: undefined }; } route.__children ??= unwrapChildren(route.children); const children = route.__children ?? []; const exact = !route.__children && !route.children; if (!match) { match = matchPath(routepath, pathname, exact, parentKeys, parentParams); if (match) { return { value: { keys: match.keys, params: match.params, path: match.path, route, }, }; } } if (match && children.length > 0) { while (childIndex < children.length) { if (!childMatches) { const childRoute = children[childIndex]; childRoute.parent = route; let matchedLength = match.path.length; if (matchedLength > 0 && pathname.charAt(matchedLength) === '/') { matchedLength += 1; } childMatches = matchRoute( childRoute, pathname.substring(matchedLength), ignoreLeadingSlash, match.keys, match.params, ); } const childMatch = childMatches.next(routeToSkip); if (!childMatch.done) { return { done: false, value: childMatch.value, }; } childMatches = null; childIndex += 1; } } return { done: true, value: undefined }; }, }; } export default matchRoute; ================================================ FILE: src/resolver/resolveRoute.ts ================================================ /** * Universal Router (https://www.kriasoft.com/universal-router/) * * Copyright (c) 2015-present Kriasoft. * * This source code is licensed under the MIT license found in the * LICENSE.txt file in the root directory of this source tree. */ import type { ActionResult, MaybePromise, RouteContext } from './types.t.js'; import { isFunction } from './utils.js'; /** @internal */ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type export default function resolveRoute( context: RouteContext, ): MaybePromise>> { if (isFunction(context.route.action)) { // @ts-expect-error: ignore "never" type here return context.route.action(context); } return undefined; } ================================================ FILE: src/resolver/resolver.ts ================================================ /** * Universal Router (https://www.kriasoft.com/universal-router/) * * Copyright (c) 2015-present Kriasoft. * * This source code is licensed under the MIT license found in the * LICENSE.txt file in the root directory of this source tree. */ import type { EmptyObject } from 'type-fest'; import matchRoute, { type MatchWithRoute } from './matchRoute.js'; import defaultResolveRoute from './resolveRoute.js'; import type { ActionResult, Route, Match, MaybePromise, ResolveContext, RouteContext } from './types.t.js'; import { getNotFoundError, getRoutePath, isString, NotFoundError, notFoundResult, toArray } from './utils.js'; function isDescendantRoute( route?: Route, maybeParent?: Route, ) { let _route = route; while (_route) { _route = _route.parent; if (_route === maybeParent) { return true; } } return false; } function isRouteContext(value: unknown): value is RouteContext { return ( !!value && typeof value === 'object' && 'next' in value && 'params' in value && 'result' in value && 'route' in value ); } export interface ResolutionErrorOptions { code?: number; cause?: unknown; } /** * An error that is thrown when a route resolution fails. */ export class ResolutionError extends Error { /** * The resolution error cause, possibly an error thrown from the action callback. */ override readonly cause?: unknown; /** * A HTTP status code associated with the error. */ readonly code?: number; /** * The context object associated with the route that was not found. */ readonly context: RouteContext; constructor(context: RouteContext, options?: ResolutionErrorOptions) { let errorMessage = `Path '${context.pathname}' is not properly resolved due to an error.`; const routePath = getRoutePath(context.route); if (routePath) { errorMessage += ` Resolution had failed on route: '${routePath}'`; } super(errorMessage); this.cause = options?.cause; this.code = options?.code; this.context = context; } /** * Logs the error message to the console as a warning. */ warn(): void { console.warn(this.message); } } function updateChainForRoute( context: RouteContext, match: Match, ) { const { path, route } = match; if (route && !route.__synthetic) { const item = { path, route }; if (route.parent && context.chain) { for (let i = context.chain.length - 1; i >= 0; i--) { if (context.chain[i].route === route.parent) { break; } context.chain.pop(); } } context.chain?.push(item); } } /** * A callback function that handles errors during route resolution. */ export type ErrorHandlerCallback = (error: unknown) => T; /** * A callback function that resolves a route. It is used as a fallback in case * the route is not correctly resolved. */ export type ResolveRouteCallback = ( context: RouteContext, ) => MaybePromise>>; /** * Options for the constructor of the `Resolver` class. * * @interface */ export type ResolverOptions = Readonly<{ baseUrl?: string; context?: RouteContext; errorHandler?: ErrorHandlerCallback; resolveRoute?: ResolveRouteCallback; }>; class Resolver { /** * The base URL for all routes in the router instance. By default, * if the base element exists in the ``, vaadin-router * takes the `` attribute value, resolved against the current * `document.URL`. */ readonly baseUrl: string; #context: RouteContext; readonly errorHandler?: ErrorHandlerCallback; readonly resolveRoute: ResolveRouteCallback; readonly #root: Route; constructor(routes: ReadonlyArray> | Route, options?: ResolverOptions); constructor( routes: ReadonlyArray> | Route, { baseUrl = '', context, errorHandler, resolveRoute = defaultResolveRoute }: ResolverOptions = {}, ) { if (Object(routes) !== routes) { throw new TypeError('Invalid routes'); } this.baseUrl = baseUrl; this.errorHandler = errorHandler; this.resolveRoute = resolveRoute; if (Array.isArray(routes)) { // @FIXME: We should have a route array instead of a single route object // to avoid type clash because of a missing `R` part of a route. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions this.#root = { __children: routes, __synthetic: true, action: () => undefined, path: '', } as Route; } else { this.#root = { ...routes, parent: undefined }; } this.#context = { ...context!, hash: '', // eslint-disable-next-line @typescript-eslint/require-await async next() { return notFoundResult; }, params: {}, pathname: '', resolver: this, route: this.#root, search: '', chain: [], }; } /** * The root route. */ get root(): Route { return this.#root; } /** * The current route context. */ get context(): RouteContext { return this.#context; } /** * If the baseUrl property is set, transforms the baseUrl and returns the full * actual `base` string for using in the `new URL(path, base);` and for * prepernding the paths with. The returned base ends with a trailing slash. * * Otherwise, returns empty string. */ protected get __effectiveBaseUrl(): string { return this.baseUrl ? new URL(this.baseUrl, document.baseURI || document.URL).href.replace(/[^/]*$/u, '') : ''; } /** * Returns the current list of routes (as a shallow copy). Adding / removing * routes to / from the returned array does not affect the routing config, * but modifying the route objects does. * * @public */ getRoutes(): ReadonlyArray> { return [...(this.#root.__children ?? [])]; } /** * Removes all existing routes from the routing config. * * @public */ removeRoutes(): void { this.#root.__children = []; } /** * Asynchronously resolves the given pathname, i.e. finds all routes matching * the pathname and tries resolving them one after another in the order they * are listed in the routes config until the first non-null result. * * Returns a promise that is fulfilled with the return value of an object that consists of the first * route handler result that returns something other than `null` or `undefined` and context used to get this result. * * If no route handlers return a non-null result, or if no route matches the * given pathname the returned promise is rejected with a 'page not found' * `Error`. * * @param pathnameOrContext - the pathname to * resolve or a context object with a `pathname` property and other * properties to pass to the route resolver functions. */ async resolve(pathnameOrContext: ResolveContext | string): Promise>> { const self = this; const context: RouteContext = { ...this.#context, ...(isString(pathnameOrContext) ? { pathname: pathnameOrContext } : pathnameOrContext), // eslint-disable-next-line @typescript-eslint/no-use-before-define next, }; const match = matchRoute( this.#root, this.__normalizePathname(context.pathname) ?? context.pathname, !!this.baseUrl, ); const resolve = this.resolveRoute; let matches: IteratorResult, undefined> | null = null; let nextMatches: IteratorResult, undefined> | null = null; let currentContext = context; async function next( resume: boolean = false, parent: Route | undefined = matches?.value?.route, prevResult?: ActionResult>, ): Promise>> { const routeToSkip = prevResult === null ? matches?.value?.route : undefined; matches = nextMatches ?? match.next(routeToSkip); nextMatches = null; if (!resume) { if (!!matches.done || !isDescendantRoute(matches.value.route, parent)) { nextMatches = matches; return notFoundResult; } } if (matches.done) { throw getNotFoundError(context); } currentContext = { ...context, params: matches.value.params, route: matches.value.route, chain: currentContext.chain?.slice(), }; updateChainForRoute(currentContext, matches.value); const resolution = await resolve(currentContext); if (resolution !== null && resolution !== undefined && resolution !== notFoundResult) { currentContext.result = isRouteContext(resolution) ? resolution.result : resolution; self.#context = currentContext; return currentContext; } return await next(resume, parent, resolution); } try { return await next(true, this.#root); } catch (error: unknown) { const _error = error instanceof NotFoundError ? error : new ResolutionError(currentContext as RouteContext, { code: 500, cause: error }); if (this.errorHandler) { currentContext.result = this.errorHandler(_error); return currentContext; } throw error; } } /** * Sets the routing config (replacing the existing one). * * @param routes - a single route or an array of those * (the array is shallow copied) */ setRoutes(routes: ReadonlyArray> | Route): object { this.#root.__children = [...toArray(routes)]; return {}; } /** * If the baseUrl is set, matches the pathname with the router’s baseUrl, * and returns the local pathname with the baseUrl stripped out. * * If the pathname does not match the baseUrl, returns undefined. * * If the `baseUrl` is not set, returns the unmodified pathname argument. */ protected __normalizePathname(pathname: string): string | undefined { if (!this.baseUrl) { // No base URL, no need to transform the pathname. return pathname; } const base = this.__effectiveBaseUrl; // Convert pathname to a valid URL constructor argument const url = pathname.startsWith('/') ? new URL(base).origin + pathname : `./${pathname}`; const normalizedUrl = new URL(url, base).href; if (normalizedUrl.startsWith(base)) { return normalizedUrl.slice(base.length); } return undefined; } /** * Appends one or several routes to the routing config and returns the * effective routing config after the operation. * * @param routes - a single route or an array of those * (the array is shallow copied) */ protected addRoutes(routes: ReadonlyArray> | Route): ReadonlyArray> { this.#root.__children = [...(this.#root.__children ?? []), ...toArray(routes)]; return this.getRoutes(); } } export default Resolver; ================================================ FILE: src/resolver/types.t.ts ================================================ import type { EmptyObject } from 'type-fest'; import type Resolver from './resolver.js'; import type { NotFoundResult } from './utils.js'; /* ======================== * Common Types * ======================== */ /** * Represents either a value or a promise of a value. * * @typeParam T - The type of the value. */ export type MaybePromise = Promise | T; /** * A result of a {@link Route.action}. * * @typeParam T - The type of the result. */ export type ActionResult = T | NotFoundResult | null | undefined | void; /* ======================== * Resolver-Specific Types * ======================== */ /** * A function that dynamically creates children of a route. * * @typeParam T - The type of the result produced by the route. * @typeParam R - The type of additional route-specific data. Defaults to an * empty object. * @typeParam C - The type of user-defined context-specific data. Defaults to an * empty object. * * @param context - The context of the current route. * * @deprecated The route children callback is deprecated and will be removed in * the next major version. * * @interface */ export type ChildrenCallback = ( context: RouteChildrenContext, ) => MaybePromise | ReadonlyArray> | void>; /** * Defines a single route. * * A route represents a single or multiple sections in the URL. It defines the * behavior of a page in response to URL updates. A route can act as a content * producer or as middleware for child routes. * * @typeParam T - The type of the result produced by the route. * @typeParam R - The type of additional route-specific data. Defaults to an * empty object. * @typeParam C - The type of user-defined context-specific data. Defaults to an * empty object. * * @interface */ export type Route = Readonly<{ /** * The name of the route. */ name?: string; /** * The path pattern that the route matches. */ path: string; /** * An action that is executed when the route is resolved. * * Actions are executed recursively from the root route to the child route and * can either produce content or perform actions before or after the child's * action. * * @param context - The context of the current route. * * @returns The result of the route resolution. It could be either a value * produced by the action or a new context to continue the resolution process. * * @internal */ action?( this: Route, context: RouteContext, commands: never, ): MaybePromise>>; }> & { /** @internal */ __children?: ReadonlyArray>; /** @internal */ __synthetic?: true; children?: ReadonlyArray> | ChildrenCallback; parent?: Route; fullPath?: string; } & R; /** * A matched route with its associated path. * * @typeParam T - The type of the result produced by the route. * @typeParam R - The type of additional route-specific data. Defaults to an * empty object. * @typeParam C - The type of user-defined context-specific data. Defaults to an * empty object. * * @internal */ export type Match = Readonly<{ /** The path of the matched route. */ path: string; /** The route object associated with the matched path. */ route?: Route; }>; /** * An item of the resolved route sequence. * * @typeParam T - The type of the result produced by the route. * @typeParam R - The type of additional route-specific data. Defaults to an * empty object. * @typeParam C - The type of user-defined context-specific data. Defaults to an * empty object. * * @interface */ export type ChainItem = { /** A DOM element associated with the route. */ element?: Element; /** The path of the route. */ path: string; /** The route object containing route-specific information. */ route: Route; }; /** * The context for a `resolve` operation that can be extended with * the user-defined properties. * * @typeParam C - The type of user-defined context-specific data. Defaults to an * empty object. * * @interface */ export type ResolveContext = Readonly<{ /** The current location. */ pathname: string; }> & C; /** * The context for a {@link Route.action} that could be used to access the * route-specific data during the resolution process. * * @typeParam T - The type of the result produced by the route. * @typeParam R - The type of additional route-specific data. Defaults to * `EmptyObject`. * @typeParam C - The type of additional context-specific data. Defaults to * `EmptyObject`. * * @interface */ export type RouteContext = Readonly<{ /** * The {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/hash | hash} * fragment of the URL. */ hash?: string; /** * The {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/search | search} * query string of the URL. */ search?: string; /** * The sequence of the resolved route items, so said the path from the root * route to the current route. */ chain?: Array>; /** * The parameters resolved from the current URL. */ params: IndexedParams; /** * The resolver instance. */ resolver?: Resolver; /** * The URL from which a redirect occurred. */ redirectFrom?: string; /** * The current route. */ route: Route; /** * Proceed to the next route in the chain, down the route tree. */ next( resume?: boolean, parent?: Route, prevResult?: ActionResult>, ): Promise>>; }> & { /** @internal */ __divergedChainIndex?: number; /** @internal */ __redirectCount?: number; /** @internal */ __renderId: number; /** @internal */ __skipAttach?: boolean; /** * The result of the route resolution. It could be either a value produced by * the {@link Route.action} or a new context to continue the resolution * process. */ result?: T | RouteContext; } & ResolveContext; /** * Represents the context that is accessible from the route children callback. * It is the a {@link RouteContext} without the 'next' property. * * @typeParam T - The type of the route parameters. * @typeParam R - The type of the route's resolved data. Defaults to `EmptyObject`. * @typeParam C - The type of the route's context. Defaults to `EmptyObject`. * * @deprecated The route children callback is deprecated and will be removed in * the next major version. * * @interface */ export type RouteChildrenContext = Omit< RouteContext, 'next' >; export type PrimitiveParamValue = string | number | null; /** * The value of a parameter resolved from the URL. */ export type ParamValue = PrimitiveParamValue | readonly PrimitiveParamValue[]; /** * The collection of parameters resolved from the URL. * * @remarks * Parameters are the parts of the URL represented by the placeholders in the * route path. Placeholders are often named, and these names become the keys of * the `IndexedParams` object. * * If a placeholder is unnamed, its index in the path becomes the key. */ export type IndexedParams = Readonly>; export type Params = IndexedParams | ParamValue[]; ================================================ FILE: src/resolver/utils.ts ================================================ import type { ChildrenCallback, Route, RouteContext } from './types.t.js'; /** * {@inheritDoc "".NotFoundError} */ export const notFoundResult = Symbol('NotFoundResult'); /** * A special result to be returned from a route action to indicate that the * route was not found. */ export type NotFoundResult = typeof notFoundResult; /** * An error to be thrown when a route is not found. */ export class NotFoundError extends Error { /** * The HTTP status code to be used when the route is not found. */ readonly code: number; /** * The context object associated with the route that was not found. */ readonly context: RouteContext; constructor(context: RouteContext) { // eslint-disable-next-line @typescript-eslint/no-use-before-define super(log(`Page not found (${context.pathname})`)); this.context = context; this.code = 404; } } /** @internal */ export function isObject(o: unknown): o is object { // guard against null passing the typeof check return typeof o === 'object' && !!o; } /** @internal */ export function isFunction unknown>(f: unknown): f is F { return typeof f === 'function'; } /** @internal */ export function isString(s: unknown): s is string { return typeof s === 'string'; } /** @internal */ export function toArray(value: T | readonly T[] = []): readonly T[] { return Array.isArray(value) ? value : [value]; } /** @internal */ export function log(msg: string): string { return `[Vaadin.Router] ${msg}`; } /** @internal */ export function getNotFoundError( context: RouteContext, ): NotFoundError { return new NotFoundError(context); } /** @internal */ export function resolvePath(path?: string | readonly string[]): string { return (Array.isArray(path) ? path[0] : path) ?? ''; } /** @internal */ export function getRoutePath(route: Route | undefined): string { return resolvePath(route?.path); } /** @internal */ export function unwrapChildren( children: ChildrenCallback | ReadonlyArray> | undefined, ): ReadonlyArray> | undefined { return Array.isArray>>(children) && children.length > 0 ? children : undefined; } ================================================ FILE: src/router-config.ts ================================================ import './router-meta.js'; ================================================ FILE: src/router-meta.ts ================================================ import { usageStatistics } from '@vaadin/vaadin-usage-statistics/vaadin-usage-statistics.js'; // @ts-expect-error: Generated function // eslint-disable-next-line @typescript-eslint/no-unsafe-call __REGISTER__(); usageStatistics(); ================================================ FILE: src/router.ts ================================================ /* eslint-disable @typescript-eslint/consistent-return */ import { compile } from 'path-to-regexp'; import type { EmptyObject, Writable } from 'type-fest'; import generateUrls from './resolver/generateUrls.js'; import Resolver from './resolver/resolver.js'; import './router-config.js'; import { getNotFoundError, isFunction, isObject, isString, log, notFoundResult } from './resolver/utils.js'; import animate from './transitions/animate.js'; import { DEFAULT_TRIGGERS, setNavigationTriggers } from './triggers/navigation.js'; import type { ActionResult, Commands, ChainItem, ContextExtension, EmptyCommands, NavigationTrigger, Params, PreventAndRedirectCommands, PreventCommands, PreventResult, RedirectContextInfo, RedirectResult, ResolveContext, Route, RouteContext, RouteExtension, RouterLocation, WebComponentInterface, RouterOptions, ActionValue, NextResult, } from './types.t.js'; import { amend, copyContextWithoutNext, createLocation, createRedirect, ensureRoutes, fireRouterEvent, getMatchedPath, getPathnameForRouter, logValue, maybeCall, processNewChildren, renderElement, } from './utils.js'; const MAX_REDIRECT_COUNT = 256; function prevent(): PreventResult { return { cancel: true }; } const rootContext: RouteContext = { __renderId: -1, params: {}, route: { __synthetic: true, children: [], path: '', action() { return undefined; }, }, pathname: '', // eslint-disable-next-line @typescript-eslint/require-await async next() { return notFoundResult; }, }; /** * A simple client-side router for single-page applications. It uses * express-style middleware and has a first-class support for Web Components and * lazy-loading. Works great in Polymer and non-Polymer apps. * * Use `new Router(outlet, options)` to create a new Router instance. * * * The `outlet` parameter is a reference to the DOM node to render * the content into. * * * The `options` parameter is an optional object with options. The following * keys are supported: * * `baseUrl` — the initial value for [ * the `baseUrl` property * ](#/classes/Router#property-baseUrl) * * The Router instance is automatically subscribed to navigation events * on `window`. * * See [Live Examples](#/classes/Router/demos/demo/index.html) for the detailed usage demo and code snippets. * * See also detailed API docs for the following methods, for the advanced usage: * * * [setOutlet](#/classes/Router#method-setOutlet) – should be used to configure the outlet. * * [setTriggers](#/classes/Router#method-setTriggers) – should be used to configure the navigation events. * * [setRoutes](#/classes/Router#method-setRoutes) – should be used to configure the routes. * * Only `setRoutes` has to be called manually, others are automatically invoked when creating a new instance. * * @deprecated This library is no longer actively maintained. Vaadin uses React Router as a primary client-side routing tool. * * Consider [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) standard Web API or a library based on it, such as [@lit-labs/router](https://www.npmjs.com/package/@lit-labs/router) */ export class Router extends Resolver< ActionValue, RouteExtension, ContextExtension > { /** * Contains read-only information about the current router location: * pathname, active routes, parameters. See the * [Location type declaration](#/classes/RouterLocation) * for more details. */ location = createLocation({ resolver: this }); /** * A promise that is settled after the current render cycle completes. If * there is no render cycle in progress the promise is immediately settled * with the last render cycle result. */ ready: Promise> = Promise.resolve(this.location); readonly #addedByRouter = new WeakSet(); readonly #createdByRouter = new WeakSet(); readonly #navigationEventHandler = this.#onNavigationEvent.bind(this); #lastStartedRenderId = 0; #outlet: Element | DocumentFragment | null | undefined; /** @internal */ private __previousContext?: RouteContext; #urlForName?: ReturnType; #appearingContent: Element[] | null = null; #disappearingContent: Element[] | null = null; /** * Creates a new Router instance with a given outlet, and * automatically subscribes it to navigation events on the `window`. * Using a constructor argument or a setter for outlet is equivalent: * * ``` * const router = new Router(); * router.setOutlet(outlet); * ``` * @param outlet - a container to render the resolved route * @param options - an optional object with options */ constructor(outlet?: Element | DocumentFragment | null, options?: RouterOptions) { const baseElement = document.head.querySelector('base'); const baseHref = baseElement?.getAttribute('href'); super([], { baseUrl: baseHref ? new URL(baseHref, document.URL).href.replace(/[^/]*$/u, '') : undefined, ...options, resolveRoute: async (context) => await this.#resolveRoute(context), }); setNavigationTriggers(Object.values(DEFAULT_TRIGGERS)); this.setOutlet(outlet); this.subscribe(); } async #resolveRoute(context: RouteContext): Promise> { const { route } = context; if (isFunction(route.children)) { let children = await route.children(copyContextWithoutNext(context)); // The route.children() callback might have re-written the // route.children property instead of returning a value if (!isFunction(route.children)) { // eslint-disable-next-line no-param-reassign ({ children } = route); } processNewChildren(children, route); } const commands: Commands = { component: (component: string) => { const element = document.createElement(component); this.#createdByRouter.add(element); return element; }, prevent, redirect: (path) => createRedirect(context, path), }; return await Promise.resolve() .then(async () => { if (this.#isLatestRender(context)) { // eslint-disable-next-line @typescript-eslint/unbound-method return await maybeCall(route.action, route, context, commands); } }) .then((result) => { if (result != null && (typeof result === 'object' || typeof result === 'symbol')) { // Actions like `() => import('my-view.js')` are not expected to // end the resolution, despite the result is not empty. Checking // the result with a whitelist of values that end the resolution. if ( result instanceof HTMLElement || result === notFoundResult || (isObject(result) && 'redirect' in result) ) { return result; } } if (isString(route.redirect)) { return commands.redirect(route.redirect); } }) .then((result) => { if (result != null) { return result; } if (isString(route.component)) { return commands.component(route.component); } }); } /** * Sets the router outlet (the DOM node where the content for the current * route is inserted). Any content pre-existing in the router outlet is * removed at the end of each render pass. * * @remarks * This method is automatically invoked first time when creating a new Router * instance. * * @param outlet - the DOM node where the content for the current route is * inserted. */ setOutlet(outlet?: Element | DocumentFragment | null): void { if (outlet) { this.#ensureOutlet(outlet); } this.#outlet = outlet; } /** * Returns the current router outlet. The initial value is `undefined`. * * @returns the current router outlet (or `undefined`) */ getOutlet(): Element | DocumentFragment | null | undefined { return this.#outlet; } /** * Sets the routing config (replacing the existing one) and triggers a * navigation event so that the router outlet is refreshed according to the * current `window.location` and the new routing config. * * Each route object may have the following properties, listed here in the processing order: * * `path` – the route path (relative to the parent route if any) in the * [express.js syntax](https://expressjs.com/en/guide/routing.html#route-paths). * * * `children` – an array of nested routes or a function that provides this * array at the render time. The function can be synchronous or asynchronous: * in the latter case the render is delayed until the returned promise is * resolved. The `children` function is executed every time when this route is * being rendered. This allows for dynamic route structures (e.g. backend-defined), * but it might have a performance impact as well. In order to avoid calling * the function on subsequent renders, you can override the `children` property * of the route object and save the calculated array there * (via `context.route.children = [ route1, route2, ...];`). * Parent routes are fully resolved before resolving the children. Children * 'path' values are relative to the parent ones. * * * `action` – the action that is executed before the route is resolved. * The value for this property should be a function, accepting `context` * and `commands` parameters described below. If present, this function is * always invoked first, disregarding of the other properties' presence. * The action can return a result directly or within a `Promise`, which * resolves to the result. If the action result is an `HTMLElement` instance, * a `commands.component(name)` result, a `commands.redirect(path)` result, * or a `context.next()` result, the current route resolution is finished, * and other route config properties are ignored. * See also **Route Actions** section in [Live Examples](#/classes/Router/demos/demo/index.html). * * * `redirect` – other route's path to redirect to. Passes all route parameters to the redirect target. * The target route should also be defined. * See also **Redirects** section in [Live Examples](#/classes/Router/demos/demo/index.html). * * * `component` – the tag name of the Web Component to resolve the route to. * The property is ignored when either an `action` returns the result or `redirect` property is present. * If route contains the `component` property (or an action that return a component) * and its child route also contains the `component` property, child route's component * will be rendered as a light dom child of a parent component. * * * `name` – the string name of the route to use in the * [`router.urlForName(name, params)`](#/classes/Router#method-urlForName) * navigation helper method. * * For any route function (`action`, `children`) defined, the corresponding `route` object is available inside the * callback through the `this` reference. If you need to access it, make sure you define the callback as a non-arrow * function because arrow functions do not have their own `this` reference. * * `context` object that is passed to `action` function holds the following properties: * * `context.pathname` – string with the pathname being resolved * * * `context.search` – search query string * * * `context.hash` – hash string * * * `context.params` – object with route parameters * * * `context.route` – object that holds the route that is currently being rendered. * * * `context.next()` – function for asynchronously getting the next route * contents from the resolution chain (if any) * * `commands` object that is passed to `action` function has * the following methods: * * * `commands.redirect(path)` – function that creates a redirect data * for the path specified. * * * `commands.component(component)` – function that creates a new HTMLElement * with current context. Note: the component created by this function is reused if visiting the same path twice in * row. * * @param routes - a single route or an array of those * @param skipRender - configure the router but skip rendering the * route corresponding to the current `window.location` values */ override async setRoutes( routes: Route | ReadonlyArray>, skipRender = false, ): Promise> { this.__previousContext = undefined; this.#urlForName = undefined; ensureRoutes(routes); super.setRoutes(routes); if (!skipRender) { this.#onNavigationEvent(); } return await this.ready; } protected override addRoutes(routes: Route | ReadonlyArray>): ReadonlyArray> { ensureRoutes(routes); return super.addRoutes(routes); } /** * Asynchronously resolves the given pathname and renders the resolved route * component into the router outlet. If no router outlet is set at the time of * calling this method, or at the time when the route resolution is completed, * a `TypeError` is thrown. * * Returns a promise that is fulfilled with the router outlet DOM Element | DocumentFragment after * the route component is created and inserted into the router outlet, or * rejected if no route matches the given path. * * If another render pass is started before the previous one is completed, the * result of the previous render pass is ignored. * * @param pathnameOrContext - the pathname to render or a context object with * a `pathname` property, optional `search` and `hash` properties, and other * properties to pass to the resolver. * @param shouldUpdateHistory - update browser history with the rendered * location */ async render( pathnameOrContext: string | ResolveContext, shouldUpdateHistory: boolean = false, ): Promise> { this.#lastStartedRenderId += 1; const renderId = this.#lastStartedRenderId; const context = { ...(rootContext as RouteContext), ...(isString(pathnameOrContext) ? { hash: '', search: '', pathname: pathnameOrContext } : pathnameOrContext), __renderId: renderId, } satisfies RouteContext; this.ready = this.#doRender(context, shouldUpdateHistory); return await this.ready; } async #doRender(context: RouteContext, shouldUpdateHistory: boolean) { const { __renderId: renderId } = context; try { // Find the first route that resolves to a non-empty result const ctx = await this.resolve(context); // Process the result of this.resolve() and handle all special commands: // (redirect / prevent / component). If the result is a 'component', // then go deeper and build the entire chain of nested components matching // the pathname. Also call all 'on before' callbacks along the way. const contextWithChain = await this.#fullyResolveChain(ctx); if (!this.#isLatestRender(contextWithChain)) { return this.location; } const previousContext = this.__previousContext; // Check if the render was prevented and make an early return in that case if (contextWithChain === previousContext) { // Replace the history with the previous context // to make sure the URL stays the same. this.#updateBrowserHistory(previousContext, true); return this.location; } this.location = createLocation(contextWithChain); if (shouldUpdateHistory) { // Replace only if first render redirects, so that we don’t leave // the redirecting record in the history this.#updateBrowserHistory(contextWithChain, renderId === 1); } fireRouterEvent('location-changed', { router: this, location: this.location, }); // Skip detaching/re-attaching there are no render changes if (contextWithChain.__skipAttach) { this.#copyUnchangedElements(contextWithChain, previousContext); this.__previousContext = contextWithChain; return this.location; } this.#addAppearingContent(contextWithChain, previousContext); const animationDone = this.#animateIfNeeded(contextWithChain); this.#runOnAfterEnterCallbacks(contextWithChain); this.#runOnAfterLeaveCallbacks(contextWithChain, previousContext); await animationDone; if (this.#isLatestRender(contextWithChain)) { // If there is another render pass started after this one, // the 'disappearing content' would be removed when the other // render pass calls `this.__addAppearingContent()` this.#removeDisappearingContent(); this.__previousContext = contextWithChain; return this.location; } } catch (error: unknown) { if (renderId === this.#lastStartedRenderId) { if (shouldUpdateHistory) { this.#updateBrowserHistory(this.context); } for (const child of this.#outlet?.children ?? []) { child.remove(); } this.location = createLocation(Object.assign(context, { resolver: this })); fireRouterEvent('error', { router: this, error, ...context, }); throw error; } } return this.location; } // `topOfTheChainContextBeforeRedirects` is a context coming from Resolver.resolve(). // It would contain a 'redirect' route or the first 'component' route that // matched the pathname. There might be more child 'component' routes to be // resolved and added into the chain. This method would find and add them. // `contextBeforeRedirects` is the context containing such a child component // route. It's only necessary when this method is called recursively (otherwise // it's the same as the 'top of the chain' context). // // Apart from building the chain of child components, this method would also // handle 'redirect' routes, call 'onBefore' callbacks and handle 'prevent' // and 'redirect' callback results. async #fullyResolveChain( topOfTheChainContextBeforeRedirects: RouteContext, contextBeforeRedirects: RouteContext = topOfTheChainContextBeforeRedirects, ): Promise> { const contextAfterRedirects = await this.#findComponentContextAfterAllRedirects(contextBeforeRedirects); const redirectsHappened = contextAfterRedirects !== contextBeforeRedirects; const topOfTheChainContextAfterRedirects = redirectsHappened ? contextAfterRedirects : topOfTheChainContextBeforeRedirects; const matchedPath = getPathnameForRouter(getMatchedPath(contextAfterRedirects.chain ?? []), this); const isFound = matchedPath === contextAfterRedirects.pathname; // Recursive method to try matching more child and sibling routes const findNextContextIfAny = async ( context: RouteContext, parent: Route | undefined = context.route, prevResult?: NextResult | null, ): Promise> => { const nextContext = await context.next(false, parent, prevResult); if (nextContext === null || nextContext === notFoundResult) { // Next context is not found in children, ... if (isFound) { // ...but original context is already fully matching - use it return context; } else if (parent.parent != null) { // ...and there is no full match yet - step up to check siblings return await findNextContextIfAny(context, parent.parent, nextContext); } return nextContext; } return nextContext; }; const nextContext = await findNextContextIfAny(contextAfterRedirects); if (nextContext == null || nextContext === notFoundResult) { throw getNotFoundError, ContextExtension>( topOfTheChainContextAfterRedirects, ); } return nextContext !== contextAfterRedirects ? await this.#fullyResolveChain(topOfTheChainContextAfterRedirects, nextContext) : await this.#amendWithOnBeforeCallbacks(contextAfterRedirects); } async #findComponentContextAfterAllRedirects(context: RouteContext): Promise> { const { result } = context; if (result instanceof HTMLElement) { renderElement(context, result as WebComponentInterface); return context; } else if (result && 'redirect' in result) { const ctx = await this.#redirect(result.redirect, context.__redirectCount, context.__renderId); return await this.#findComponentContextAfterAllRedirects(ctx); } throw result instanceof Error ? result : new Error( log( `Invalid route resolution result for path "${context.pathname}". ` + `Expected redirect object or HTML element, but got: "${logValue(result)}". ` + `Double check the action return value for the route.`, ), ); } async #amendWithOnBeforeCallbacks(contextWithFullChain: RouteContext): Promise> { return await this.#runOnBeforeCallbacks(contextWithFullChain).then(async (amendedContext) => { if (amendedContext === this.__previousContext || amendedContext === contextWithFullChain) { return amendedContext; } return await this.#fullyResolveChain(amendedContext); }); } async #runOnBeforeCallbacks(newContext: RouteContext): Promise> { const previousContext = (this.__previousContext ?? {}) as Partial>; const previousChain = previousContext.chain ?? []; const newChain = newContext.chain ?? []; let callbacks: Promise = Promise.resolve(undefined); const redirect = (pathname: string) => createRedirect(newContext, pathname) as unknown as RedirectResult; newContext.__divergedChainIndex = 0; newContext.__skipAttach = false; if (previousChain.length) { for (let i = 0; i < Math.min(previousChain.length, newChain.length); newContext.__divergedChainIndex++, i++) { if ( previousChain[i].route !== newChain[i].route || (previousChain[i].path !== newChain[i].path && previousChain[i].element !== newChain[i].element) || !this.#isReusableElement( previousChain[i].element as HTMLElement | undefined, newChain[i].element as HTMLElement | undefined, ) ) { break; } } // Skip re-attaching and notifications if element and chain do not change newContext.__skipAttach = // Same route chain newChain.length === previousChain.length && newContext.__divergedChainIndex === newChain.length && // Same element this.#isReusableElement(newContext.result, previousContext.result); if (newContext.__skipAttach) { // execute onBeforeLeave for changed segment element when skipping attach for (let i = newChain.length - 1; i >= 0; i--) { callbacks = this.#runOnBeforeLeaveCallbacks(callbacks, newContext, { prevent }, previousChain[i]); } // execute onBeforeEnter for changed segment element when skipping attach for (let i = 0; i < newChain.length; i++) { callbacks = this.#runOnBeforeEnterCallbacks( callbacks, newContext, { prevent, redirect, }, newChain[i], ); previousChain[i].element!.location = createLocation(newContext, previousChain[i].route); } } else { // execute onBeforeLeave when NOT skipping attach for (let i = previousChain.length - 1; i >= newContext.__divergedChainIndex; i--) { callbacks = this.#runOnBeforeLeaveCallbacks(callbacks, newContext, { prevent }, previousChain[i]); } } } // execute onBeforeEnter when NOT skipping attach if (!newContext.__skipAttach) { for (let i = 0; i < newChain.length; i++) { if (i < newContext.__divergedChainIndex) { if (i < previousChain.length && previousChain[i].element) { previousChain[i].element!.location = createLocation(newContext, previousChain[i].route); } } else { callbacks = this.#runOnBeforeEnterCallbacks( callbacks, newContext, { prevent, redirect, }, newChain[i], ); if (newChain[i].element) { newChain[i].element!.location = createLocation(newContext, newChain[i].route); } } } } return await callbacks.then(async (amendmentResult: ActionResult) => { if (amendmentResult && isObject(amendmentResult)) { if ('cancel' in amendmentResult && this.__previousContext) { this.__previousContext.__renderId = newContext.__renderId; return this.__previousContext; } if ('redirect' in amendmentResult) { return await this.#redirect(amendmentResult.redirect, newContext.__redirectCount, newContext.__renderId); } } return newContext; }); } async #runOnBeforeLeaveCallbacks( callbacks: Promise, newContext: RouteContext, commands: PreventCommands, chainElement: ChainItem, ): Promise { const location = createLocation(newContext); let result: ActionResult = await callbacks; if (this.#isLatestRender(newContext)) { const beforeLeaveFunction = amend('onBeforeLeave', chainElement.element, location, commands, this); result = beforeLeaveFunction(result); } if (!(isObject(result) && 'redirect' in result)) { return result as ActionResult; } } async #runOnBeforeEnterCallbacks( callbacks: Promise, newContext: RouteContext, commands: PreventAndRedirectCommands, chainElement: ChainItem, ): Promise { const location = createLocation(newContext, chainElement.route); const result = await callbacks; if (this.#isLatestRender(newContext)) { const beforeEnterFunction = amend('onBeforeEnter', chainElement.element, location, commands, this); return beforeEnterFunction(result); } } #isReusableElement(element?: unknown, otherElement?: unknown): boolean { if (element instanceof Element && otherElement instanceof Element) { return this.#createdByRouter.has(element) && this.#createdByRouter.has(otherElement) ? element.localName === otherElement.localName : element === otherElement; } return false; } #isLatestRender(context: Partial>): boolean { return context.__renderId === this.#lastStartedRenderId; } declare ['resolve']: ( contextOrPathname: RouteContext | string, ) => Promise & RedirectContextInfo>; async #redirect( redirectData: RedirectContextInfo, counter: number = 0, renderId: number = 0, ): Promise & RedirectContextInfo> { if (counter > MAX_REDIRECT_COUNT) { throw new Error(log(`Too many redirects when rendering ${redirectData.from}`)); } return await this.resolve({ ...(rootContext as RouteContext), pathname: this.urlForPath(redirectData.pathname, redirectData.params), redirectFrom: redirectData.from, __redirectCount: counter + 1, __renderId: renderId, }); } #ensureOutlet(outlet: Element | DocumentFragment | undefined | null = this.#outlet): void { if (!(outlet instanceof Element || outlet instanceof DocumentFragment)) { throw new TypeError( log(`Expected router outlet to be a valid DOM Element | DocumentFragment (but got ${outlet})`), ); } } // eslint-disable-next-line @typescript-eslint/class-methods-use-this #updateBrowserHistory({ pathname, search = '', hash = '' }: ResolveContext, replace?: boolean): void { if (window.location.pathname !== pathname || window.location.search !== search || window.location.hash !== hash) { const changeState = replace ? 'replaceState' : 'pushState'; window.history[changeState](null, document.title, pathname + search + hash); window.dispatchEvent(new PopStateEvent('popstate', { state: 'vaadin-router-ignore' })); } } #copyUnchangedElements( context: RouteContext, previousContext?: RouteContext, ): Element | DocumentFragment | null | undefined { // Find the deepest common parent between the last and the new component // chains. Update references for the unchanged elements in the new chain let deepestCommonParent = this.#outlet; for (let i = 0; i < (context.__divergedChainIndex ?? 0); i++) { const unchangedElement = previousContext?.chain?.[i].element; if (unchangedElement) { if (unchangedElement.parentNode === deepestCommonParent) { (context.chain![i] as Writable>).element = unchangedElement; deepestCommonParent = unchangedElement; } else { break; } } } return deepestCommonParent; } #addAppearingContent(context: RouteContext, previousContext?: RouteContext): void { this.#ensureOutlet(); // If the previous 'entering' animation has not completed yet, // stop it and remove that content from the DOM before adding new one. this.#removeAppearingContent(); // Copy reusable elements from the previousContext to current const deepestCommonParent = this.#copyUnchangedElements(context, previousContext); // Keep two lists of DOM elements: // - those that should be removed once the transition animation is over // - and those that should remain this.#appearingContent = []; this.#disappearingContent = Array.from(deepestCommonParent?.children ?? []).filter( // Only remove layout content that was added by router (e) => this.#addedByRouter.has(e) && // Do not remove the result element to avoid flickering e !== context.result, ); // Add new elements (starting after the deepest common parent) to the DOM. // That way only the components that are actually different between the two // locations are added to the DOM (and those that are common remain in the // DOM without first removing and then adding them again). let parentElement = deepestCommonParent; for (let i = context.__divergedChainIndex ?? 0; i < (context.chain?.length ?? 0); i++) { const elementToAdd = context.chain![i].element; if (elementToAdd) { parentElement?.appendChild(elementToAdd); this.#addedByRouter.add(elementToAdd); if (parentElement === deepestCommonParent) { this.#appearingContent.push(elementToAdd); } parentElement = elementToAdd; } } } #removeDisappearingContent(): void { if (this.#disappearingContent) { for (const element of this.#disappearingContent) { element.remove(); } } this.#disappearingContent = null; this.#appearingContent = null; } #removeAppearingContent(): void { if (this.#disappearingContent && this.#appearingContent) { for (const element of this.#appearingContent) { element.remove(); } this.#disappearingContent = null; this.#appearingContent = null; } } #runOnAfterLeaveCallbacks(currentContext: RouteContext, targetContext?: RouteContext): void { if (!targetContext?.chain || currentContext.__divergedChainIndex == null) { return; } // REVERSE iteration: from Z to A for (let i = targetContext.chain.length - 1; i >= currentContext.__divergedChainIndex; i--) { if (!this.#isLatestRender(currentContext)) { break; } const currentComponent = targetContext.chain[i].element; if (!currentComponent) { continue; } try { const location = createLocation(currentContext); // eslint-disable-next-line @typescript-eslint/unbound-method maybeCall(currentComponent.onAfterLeave, currentComponent, location, {} as EmptyCommands, this); } finally { if (this.#disappearingContent?.includes(currentComponent)) { for (const child of currentComponent.children) { child.remove(); } } } } } #runOnAfterEnterCallbacks(currentContext: RouteContext): void { if (!currentContext.chain || currentContext.__divergedChainIndex == null) { return; } // forward iteration: from A to Z for (let i = currentContext.__divergedChainIndex; i < currentContext.chain.length; i++) { if (!this.#isLatestRender(currentContext)) { break; } const currentComponent = currentContext.chain[i].element; if (currentComponent) { const location = createLocation(currentContext, currentContext.chain[i].route); // eslint-disable-next-line @typescript-eslint/unbound-method maybeCall(currentComponent.onAfterEnter, currentComponent, location, {}, this); } } } async #animateIfNeeded(context: RouteContext): Promise> { const from = this.#disappearingContent?.[0]; const to = this.#appearingContent?.[0]; const promises = []; const { chain = [] } = context; let config; for (let i = chain.length - 1; i >= 0; i--) { if (chain[i].route.animate) { config = chain[i].route.animate; break; } } if (from && to && config) { const leave = isObject(config) && config.leave ? config.leave : 'leaving'; const enter = isObject(config) && config.enter ? config.enter : 'entering'; promises.push(animate(from, leave)); promises.push(animate(to, enter)); } await Promise.all(promises); return context; } /** * Subscribes this instance to navigation events on the `window`. * * NOTE: beware of resource leaks. For as long as a router instance is * subscribed to navigation events, it won't be garbage collected. */ subscribe(): void { window.addEventListener('vaadin-router-go', this.#navigationEventHandler); } /** * Removes the subscription to navigation events created in the `subscribe()` * method. */ unsubscribe(): void { window.removeEventListener('vaadin-router-go', this.#navigationEventHandler); } #onNavigationEvent(event?: Event): void { const { pathname, search, hash } = event instanceof CustomEvent ? (event.detail as ResolveContext) : window.location; if (isString(this.__normalizePathname(pathname))) { if (event?.preventDefault) { event.preventDefault(); } // eslint-disable-next-line no-void void this.render({ pathname, search, hash }, true); } } /** * Configures what triggers Router navigation events: * - `POPSTATE`: popstate events on the current `window` * - `CLICK`: click events on `` links leading to the current page * * This method is invoked with the pre-configured values when creating a new Router instance. * By default, both `POPSTATE` and `CLICK` are enabled. This setup is expected to cover most of the use cases. * * See the `router-config.js` for the default navigation triggers config. Based on it, you can * create the own one and only import the triggers you need, instead of pulling in all the code, * e.g. if you want to handle `click` differently. * * See also **Navigation Triggers** section in [Live Examples](#/classes/Router/demos/demo/index.html). * * @param triggers - navigation triggers */ static setTriggers(...triggers: readonly NavigationTrigger[]): void { setNavigationTriggers(triggers); } /** * Generates a URL for the route with the given name, optionally performing * substitution of parameters. * * The route is searched in all the Router instances subscribed to * navigation events. * * **Note:** For child route names, only array children are considered. * It is not possible to generate URLs using a name for routes set with * a children function. * * @param name - The route name or the route’s `component` name. * @param params - Optional object with route path parameters. * Named parameters are passed by name (`params[name] = value`), unnamed * parameters are passed by index (`params[index] = value`). */ urlForName(name: string, params?: Params | null): string { if (!this.#urlForName) { this.#urlForName = generateUrls(this, { cacheKeyProvider(route): string | undefined { return 'component' in route && typeof route.component === 'string' ? (route as Readonly<{ component: string }>).component : undefined; }, }); } return getPathnameForRouter(this.#urlForName(name, params ?? undefined), this); } /** * Generates a URL for the given route path, optionally performing * substitution of parameters. * * @param path - String route path declared in [express.js * syntax](https://expressjs.com/en/guide/routing.html#route-paths). * @param params - Optional object with route path parameters. * Named parameters are passed by name (`params[name] = value`), unnamed * parameters are passed by index (`params[index] = value`). */ urlForPath(path: string, params?: Params | null): string { return getPathnameForRouter( compile(path)((params as Partial> | null) ?? undefined), this, ); } /** * Triggers navigation to a new path. Returns a boolean without waiting until * the navigation is complete. Returns `true` if at least one `Router` * has handled the navigation (was subscribed and had `baseUrl` matching * the `path` argument), otherwise returns `false`. * * @param path - A new in-app path string, or an URL-like object with * `pathname` string property, and optional `search` and `hash` string * properties. */ static go(path: string | ResolveContext): boolean { const { pathname, search, hash } = isString(path) ? new URL(path, 'http://a') // some base to omit origin : path; return fireRouterEvent('go', { pathname, search, hash }); } } ================================================ FILE: src/transitions/animate.ts ================================================ const willAnimate = (elem: Element) => { const name = getComputedStyle(elem).getPropertyValue('animation-name'); return name && name !== 'none'; }; const waitForAnimation = (elem: Element, cb: () => void) => { const listener = () => { elem.removeEventListener('animationend', listener); cb(); }; elem.addEventListener('animationend', listener); }; async function animate(elem: Element, className: string): Promise { elem.classList.add(className); return await new Promise((resolve: () => void) => { if (willAnimate(elem)) { const rect = elem.getBoundingClientRect(); const size = `height: ${rect.bottom - rect.top}px; width: ${rect.right - rect.left}px`; elem.setAttribute('style', `position: absolute; ${size}`); waitForAnimation(elem, () => { elem.classList.remove(className); elem.removeAttribute('style'); resolve(); }); } else { elem.classList.remove(className); resolve(); } }); } export default animate; ================================================ FILE: src/triggers/click.ts ================================================ import type { NavigationTrigger } from '../types.t.js'; import { fireRouterEvent } from '../utils.js'; /* istanbul ignore next: coverage is calculated in Chrome, this code is for IE */ function getAnchorOrigin(anchor: HTMLAnchorElement) { // IE11: on HTTP and HTTPS the default port is not included into // window.location.origin, so won't include it here either. const { port, protocol } = anchor; const defaultHttp = protocol === 'http:' && port === '80'; const defaultHttps = protocol === 'https:' && port === '443'; const host = defaultHttp || defaultHttps ? anchor.hostname // does not include the port number (e.g. www.example.org) : anchor.host; // does include the port number (e.g. www.example.org:80) return `${protocol}//${host}`; } function getNormalizedNodeName(e: EventTarget): string | undefined { if (!(e instanceof Element)) { return undefined; } return e.nodeName.toLowerCase(); } // TODO: Name correctly when the type purpose is known type __Pathable = Readonly<{ path?: readonly EventTarget[]; }>; // The list of checks is not complete: // - SVG support is missing // - the 'rel' attribute is not considered function vaadinRouterGlobalClickHandler(event: MouseEvent & __Pathable) { // ignore the click if the default action is prevented if (event.defaultPrevented) { return; } // ignore the click if not with the primary mouse button if (event.button !== 0) { return; } // ignore the click if a modifier key is pressed if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { return; } // find the element that the click is at (or within) let anchorCandidate = event.target; const path = event instanceof MouseEvent ? event.composedPath() : ((event as __Pathable).path ?? []); // FIXME(web-padawan): `Symbol.iterator` used by webcomponentsjs is broken for arrays // example to check: `for...of` loop here throws the "Not yet implemented" error // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < path.length; i++) { const target = path[i]; if ('nodeName' in target && (target as Element).nodeName.toLowerCase() === 'a') { anchorCandidate = target; break; } } while (anchorCandidate && anchorCandidate instanceof Node && getNormalizedNodeName(anchorCandidate) !== 'a') { anchorCandidate = anchorCandidate.parentNode; } // ignore the click if not at an element if (!anchorCandidate || getNormalizedNodeName(anchorCandidate) !== 'a') { return; } const anchor = anchorCandidate as HTMLAnchorElement; // ignore the click if the element has a non-default target if (anchor.target && anchor.target.toLowerCase() !== '_self') { return; } // ignore the click if the element has the 'download' attribute if (anchor.hasAttribute('download')) { return; } // ignore the click if the element has the 'router-ignore' attribute if (anchor.hasAttribute('router-ignore')) { return; } // ignore the click if the target URL is a fragment on the current page if (anchor.pathname === window.location.pathname && anchor.hash !== '') { return; } // ignore the click if the target is external to the app // In IE11 HTMLAnchorElement does not have the `origin` property const origin = anchor.origin || getAnchorOrigin(anchor); if (origin !== window.location.origin) { return; } // if none of the above, convert the click into a navigation event const { hash, pathname, search } = anchor; if (fireRouterEvent('go', { hash, pathname, search }) && event instanceof MouseEvent) { event.preventDefault(); // for a click event, the scroll is reset to the top position. // FIXME: undefined here? if (event.type === 'click') { window.scrollTo(0, 0); } } } /** * A navigation trigger for Vaadin Router that translated clicks on `` links * into Vaadin Router navigation events. * * Only regular clicks on in-app links are translated (primary mouse button, no * modifier keys, the target href is within the app's URL space). */ const CLICK: NavigationTrigger = { activate() { window.document.addEventListener('click', vaadinRouterGlobalClickHandler); }, inactivate() { window.document.removeEventListener('click', vaadinRouterGlobalClickHandler); }, }; export default CLICK; ================================================ FILE: src/triggers/navigation.ts ================================================ import type { NavigationTrigger } from '../types.t.js'; import CLICK from './click.js'; import POPSTATE from './popstate.js'; let triggers: readonly NavigationTrigger[] = []; const DEFAULT_TRIGGERS = { CLICK, POPSTATE, } as const; export { DEFAULT_TRIGGERS }; export function setNavigationTriggers(newTriggers: readonly NavigationTrigger[] = []): void { triggers.forEach((trigger) => trigger.inactivate()); newTriggers.forEach((trigger) => trigger.activate()); triggers = newTriggers; } ================================================ FILE: src/triggers/popstate.ts ================================================ import type { NavigationTrigger } from '../types.t.js'; import { fireRouterEvent } from '../utils.js'; function vaadinRouterGlobalPopstateHandler(event: PopStateEvent) { if (event.state === 'vaadin-router-ignore') { return; } const { hash, pathname, search } = window.location; fireRouterEvent('go', { hash, pathname, search }); } /** * A navigation trigger for Vaadin Router that translates popstate events into * Vaadin Router navigation events. */ const POPSTATE: NavigationTrigger = { activate() { window.addEventListener('popstate', vaadinRouterGlobalPopstateHandler); }, inactivate() { window.removeEventListener('popstate', vaadinRouterGlobalPopstateHandler); }, }; export default POPSTATE; ================================================ FILE: src/types.t.ts ================================================ import type { EmptyObject, RequireAtLeastOne } from 'type-fest'; import type { ResolutionError, ResolverOptions } from './resolver/resolver.js'; import type { ActionResult as _ActionResult, ChildrenCallback as _ChildrenCallback, ChainItem as _ChainItem, RouteChildrenContext as _RouteChildrenContext, IndexedParams, MaybePromise, Params, ParamValue, PrimitiveParamValue, Route as _Route, RouteContext as _RouteContext, } from './resolver/types.t.js'; import type { Router } from './router.js'; export type { ResolutionError, IndexedParams, Params, ParamValue, PrimitiveParamValue }; /** * A custom event that is triggered when the location changes. */ export type VaadinRouterLocationChangedEvent< R extends object = EmptyObject, C extends object = EmptyObject, > = CustomEvent< Readonly<{ /** The new location after the change */ location: RouterLocation; /** The router instance that triggered the event */ router: Router; }> >; /** * A custom event triggered by an error occurred during route resolution. * * @typeParam R - The type of additional route-specific data. Defaults to an * empty object. * @typeParam C - The type of user-defined context-specific data. Defaults to an * empty object. */ export type VaadinRouterErrorEvent = CustomEvent< Readonly<{ /** The error object. */ error: ResolutionError; /** The router instance that triggered the error event. */ router: Router; }> & RouteContext >; /** * A custom event triggered when the user navigates to a new location. */ export type VaadinRouterGoEvent = CustomEvent; declare global { interface WindowEventMap { 'vaadin-router-go': VaadinRouterGoEvent; 'vaadin-router-location-changed': VaadinRouterLocationChangedEvent; 'vaadin-router-error': VaadinRouterErrorEvent; } interface ArrayConstructor { isArray(arg: unknown): arg is T; } } /** * A context information for a redirect operation. */ export type RedirectContextInfo = Readonly<{ /** The original path from which the redirect is happening. */ from: string; /** An object containing URL parameters related to the redirect. */ params: IndexedParams; /** The pathname of the new URL to which the redirect is directed. */ pathname: string; }>; /** * A result that can be returned from a route action to request a redirect to * a different location. */ export interface RedirectResult { /** The path info to redirect to. */ readonly redirect: RedirectContextInfo; } /** * A result that can be returned from a route action to prevent the navigation. */ export interface PreventResult { /** A flag indicating that the navigation should be prevented. */ readonly cancel: true; } /** * A controller to set up and tear down navigation event listeners. */ export interface NavigationTrigger { /** Sets up navigation listeners. */ activate(): void; /** Tears down navigation listeners. */ inactivate(): void; } /** * A value of a result that can be returned from the router action. */ export type ActionValue = HTMLElement | PreventResult | RedirectResult; /** * A result of the {@link RouteContext.next} function. */ export type NextResult = _ActionResult>; /** * A result of the {@link RouteExtension.action | Route.action}. */ export type ActionResult = _ActionResult; /** * {@inheritDoc "".ChainItem} */ export type ChainItem = _ChainItem< ActionValue, RouteExtension, ContextExtension > & Readonly<{ element?: WebComponentInterface; }>; /** * A specialized extension for the internal Resolver's * {@link "".Context | Context} object that redefines some types to * make it compatible with the {@link Router}. * * @internal */ export type ContextExtension = Readonly<{ resolver?: Router; chain?: Array>; }> & C; /** * {@inheritDoc "".ChildrenCallback} */ export type ChildrenCallback = _ChildrenCallback< ActionValue, RouteExtension, ContextExtension >; /** * An specialized extension for the internal Resolver's {@link "".Route | Route} * object that redefines some types to make it compatible with the * {@link Router}. * * @internal */ export type RouteExtension = RequireAtLeastOne<{ children?: ChildrenCallback | ReadonlyArray>; component?: string; redirect?: string; /** * An action that is executed when the route is resolved. * * Actions are executed recursively from the root route to the child route and * can either produce content or perform actions before or after the child's * action. * * @param context - The context of the current route. * * @returns The result of the route resolution. It could be either a value * produced by the action or a new context to continue the resolution process. */ action?( this: Route, context: RouteContext, commands: Commands, ): MaybePromise>; }> & { animate?: AnimateCustomClasses | boolean; } & R; /** * {@inheritDoc "".RouteContext} * @interface */ export type RouteContext = _RouteContext< ActionValue, RouteExtension, ContextExtension >; /** * {@inheritDoc "".RouteChildrenContext} * @interface */ export type RouteChildrenContext< R extends object = EmptyObject, C extends object = EmptyObject, > = _RouteChildrenContext, ContextExtension>; /** * {@inheritDoc "".Route} * @interface */ export type Route = _Route< ActionValue, RouteExtension, ContextExtension >; /** * {@inheritDoc "".ResolverOptions} * @interface */ export type RouterOptions = ResolverOptions< ActionValue, RouteExtension, ContextExtension >; /** * Describes the state of a router at a given point in time. It is available for * your application code in several ways: * - as the `router.location` property, * - as the `location` property set by Vaadin Router on every view Web * Component, * - as the `location` argument passed by Vaadin Router into view Web Component * lifecycle callbacks, * - as the `event.detail.location` of the global Vaadin Router events. */ export interface RouterLocation { /** * The base URL used in the router. See [the `baseUrl` property * ](#/classes/Router#property-baseUrl) in the Router. * * @public */ baseUrl: string; /** * The fragment identifier (including hash character) for the current page. * * @public */ hash: string; /** * A bag of key-value pairs with parameters for the current location. Named * parameters are available by name, unnamed ones - by index (e.g. for the * `/users/:id` route the `:id` parameter is available as `location.params.id`). * * See the **Route Parameters** section of the * [live demos](#/classes/Router/demos/demo/index.html) for more * details. * * @public */ params: IndexedParams; /** * The pathname, as it was entered in the browser address bar * (e.g. `/users/42/messages/12/edit`). It always starts with a `/` (slash). * * @public */ pathname: string; /** * The original pathname string in case if this location is a result of a * redirect. * * E.g. with the routes config as below a navigation to `/u/12` produces a * location with `pathname: '/user/12'` and `redirectFrom: '/u/12'`. * * ```ts * setRoutes([ * {path: '/u/:id', redirect: '/user/:id'}, * {path: '/user/:id', component: 'x-user-view'}, * ]); * ``` * * See the **Redirects** section of the * [live demos](#/classes/Router/demos/demo/index.html) for more * details. * * @public */ redirectFrom?: string; /** * The route object associated with `this` Web Component instance. * * This property is defined in the `location` objects that are passed as * parameters into Web Component lifecycle callbacks, and the `location` * property set by Vaadin Router on the Web Components. * * This property is undefined in the `location` objects that are available * as `router.location`, and in the `location` that is included into the * global router event details. * * @public */ route: Route | null; /** * A list of route objects that match the current pathname. This list has * one element for each route that defines a parent layout, and then the * element for the route that defines the view. * * See the **Getting Started** section of the * [live demos](#/classes/Router/demos/demo/index.html) for more * details on child routes and nested layouts. * * @public */ routes: ReadonlyArray>; /** * The query string portion of the current url. * * @public */ search: string; /** * The query search parameters of the current url. * * @public */ searchParams: URLSearchParams; /** * Returns a URL corresponding to the route path and the parameters of this * location. When the parameters object is given in the arguments, * the argument parameters override the location ones. * * @param params - optional object with parameters to override. * Named parameters are passed by name (`params[name] = value`), unnamed * parameters are passed by index (`params[index] = value`). * @returns generated URL * @public */ getUrl(params?: Params): string; } /** * This interface describes the lifecycle callbacks supported by Vaadin Router * on view Web Components. It exists only for documentation purposes, i.e. * you _do not need_ to extend it in your code—defining a method with a * matching name is enough (this class does not exist at the run time). * * If any of the methods described below are defined in a view Web Component, * Vaadin Router calls them at the corresponding points of the view * lifecycle. Each method can either be synchronous or asynchronous (i.e. return * a Promise). In the latter case Vaadin Router waits until the promise is * resolved and continues the navigation after that. * * Check the [documentation on the `Router` class](#/classes/Router) * to learn more. * * Lifecycle callbacks are executed after the new path is resolved and after all * `action` callbacks of the routes in the new path are executed. * * Example: * * For the following routes definition, * ``` * // router and action declarations are omitted for brevity * router.setRoutes([ * {path: '/a', action: actionA, children: [ * {path: '/b', action: actionB, component: 'component-b'}, * {path: '/c', action: actionC, component: 'component-c'} * ]} * ]); * ``` * if the router first navigates to `/a/b` path and there was no view rendered * before, the following events happen: * - actionA * - actionB * - onBeforeEnterB (if defined in component-b) * - outlet contents updated with component-b * - onAfterEnterB (if defined in component-b) * * then, if the router navigates to `/a/c`, the following events take place: * - actionA * - actionC * - onBeforeLeaveB (if defined in component-b) * - onBeforeEnterC (if defined in component-c) * - onAfterLeaveB (if defined in component-b) * - outlet contents updated with component-c * - onAfterEnterC (if defined in component-c) * * If a `Promise` is returned by any of the callbacks, it is resolved before * proceeding further. * * Any of the `onBefore...` callbacks have a possibility to prevent * the navigation and fall back to the previous navigation result. If there is * no result and this is the first resolution, an exception is thrown. * * `onAfter...` callbacks are considered as non-preventable, and their return * value is ignored. * * Other examples can be found in the * [live demos](#/classes/Router/demos/demo/index.html) and tests. */ export interface WebComponentInterface extends HTMLElement { location?: RouterLocation; name?: string; /** * Method that gets executed after the outlet contents is updated with the new * element. If the router navigates to the same path twice in a row, and this results * in rendering the same component name (if the component is created * using `component` property in the route object) or the same component instance * (if the component is created and returned inside `action` property of the route object), * in the second time the method is not called. The WebComponent instance on which the callback * has been invoked is available inside the callback through * the `this` reference. * * This callback is called asynchronously after the native * [`connectedCallback()`](https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-reactions) * defined by the Custom Elements spec. * * Return values: any return value is ignored and Vaadin Router proceeds with the navigation. * * Arguments: * * @param location - the `RouterLocation` object * @param commands - empty object * @param router - the `Router` instance */ onAfterEnter?(location: RouterLocation, commands: EmptyCommands, router: Router): void; /** * Method that gets executed when user navigates away from the component that * had defined the method, just before the element is to be removed * from the DOM. The difference between this method and `onBeforeLeave` * is that when this method is executed, there is no way to abort * the navigation. This effectively means that the corresponding component * should be resolved by the router before the method can be executed. * If the router navigates to the same path twice in a row, and this results * in rendering the same component name (if the component is created * using `component` property in the route object) or the same component instance * (if the component is created and returned inside `action` property of the route object), * in the second time the method is not called. The WebComponent instance on which the callback * has been invoked is available inside the callback through * the `this` reference. * * Return values: any return value is ignored and Vaadin Router proceeds with the navigation. * * Arguments: * * @param location - the `RouterLocation` object * @param commands - empty object * @param router - the `Router` instance */ onAfterLeave?(location: RouterLocation, commands: EmptyCommands, router: Router): void; /** * Method that gets executed before the outlet contents is updated with * the new element. The user can prevent the navigation by returning * `commands.prevent()` from the method or same value wrapped in `Promise`. * If the router navigates to the same path twice in a row, and this results * in rendering the same component name (if the component is created * using `component` property in the route object) or the same component instance * (if the component is created and returned inside `action` property of the route object), * in the second time the method is not called. In case of navigating to a different path * but within the same route object, e.g. the path has parameter or wildcard, * and this results in rendering the same component instance, the method is called if available. * The WebComponent instance on which the callback has been invoked is available inside the callback through * the `this` reference. * * Return values: * * * if the `commands.prevent()` result is returned (immediately or * as a Promise), the navigation is aborted and the outlet contents * is not updated. * * if the `commands.redirect(path)` result is returned (immediately or * as a Promise), Vaadin Router ends navigation to the current path, and * starts a new navigation cycle to the new path. * * any other return value is ignored and Vaadin Router proceeds with * the navigation. * * Arguments: * * @param location - the `RouterLocation` object * @param commands - the commands object with the following methods: * * | Property | Description * | -------------------------|------------- * | `commands.redirect(path)` | function that creates a redirect data for the path specified, to use as a return * value from the callback. * | `commands.prevent()` | function that creates a special object that can be returned to abort the current * navigation and fall back to the last one. If there is no existing one, an exception is thrown. * | `commands.redirectResult(path)` | function that creates a redirect data for the path specified, to use as a return * value from the callback. * | `commands.prevent()` | function that creates a special object that can be returned to abort the current * navigation and fall back to the last one. If there is no existing one, an exception is thrown. * * @param router - the `Router` instance */ onBeforeEnter?( location: RouterLocation, commands: Commands, router: Router, ): MaybePromise; /** * Method that gets executed when user navigates away from the component * that had defined the method. The user can prevent the navigation * by returning `commands.prevent()` from the method or same value wrapped * in `Promise`. This effectively means that the corresponding component * should be resolved by the router before the method can be executed. * If the router navigates to the same path twice in a row, and this results * in rendering the same component name (if the component is created * using `component` property in the route object) or the same component instance * (if the component is created and returned inside `action` property of the route object), * in the second time the method is not called. In case of navigating to a different path * but within the same route object, e.g. the path has parameter or wildcard, * and this results in rendering the same component instance, the method is called if available. * The WebComponent instance on which the callback has been invoked is available inside the callback through * the `this` reference. * * Return values: * * - if the `commands.prevent()` result is returned (immediately or * as a Promise), the navigation is aborted and the outlet contents * is not updated. * - any other return value is ignored and Vaadin Router proceeds with * the navigation. * * Arguments: * * @param location - the `RouterLocation` object * @param commands - the commands object with the following methods: * * | Property | Description * | -------------------|------------- * | `commands.prevent()` | function that creates a special object that can be returned to abort the current * navigation and fall back to the last one. If there is no existing one, an exception is thrown. * * @param router - the `Router` instance */ onBeforeLeave?( location: RouterLocation, commands: Commands, router: Router, ): MaybePromise; } export type ResolveContext = Readonly<{ hash?: string; pathname: string; search?: string; redirectFrom?: string; }>; export interface Commands { component(name: K): HTMLElementTagNameMap[K]; component(name: string): HTMLElement; /** * function that creates a special object that can be returned to abort * the current navigation and fall back to the last one. If there is no * existing one, an exception is thrown. */ prevent(): PreventResult; redirect(path: string): RedirectResult; } export type EmptyCommands = EmptyObject; export type PreventCommands = Pick; export type PreventAndRedirectCommands = Pick; export type AnimateCustomClasses = Readonly<{ enter?: string; leave?: string; }>; ================================================ FILE: src/utils.ts ================================================ import { compile } from 'path-to-regexp'; import type Resolver from './resolver/resolver.js'; import { isFunction, isObject, isString, log, toArray } from './resolver/utils.js'; import type { Router } from './router.js'; import type { ActionResult, ChainItem, IndexedParams, RedirectResult, Route, RouteContext, RouterLocation, WebComponentInterface, } from './types.t.js'; /** @internal */ export function ensureRoute(route?: Route): void { if (!route || !isString(route.path)) { throw new Error( log(`Expected route config to be an object with a "path" string property, or an array of such objects`), ); } if ( !isFunction(route.action) && !Array.isArray(route.children) && !isFunction(route.children) && !isString(route.component) && !isString(route.redirect) ) { throw new Error( log( `Expected route config "${route.path}" to include either "component, redirect" ` + `or "action" function but none found.`, ), ); } if (route.redirect) { ['bundle', 'component'].forEach((overriddenProp) => { if (overriddenProp in route) { console.warn( log( `Route config "${String(route.path)}" has both "redirect" and "${overriddenProp}" properties, ` + `and "redirect" will always override the latter. Did you mean to only use "${overriddenProp}"?`, ), ); } }); } } /** @internal */ export function ensureRoutes( routes: Route | ReadonlyArray>, ): void { toArray(routes).forEach((route) => ensureRoute(route)); } /** @internal */ export function copyContextWithoutNext({ next: _, ...context }: RouteContext): Omit, 'next'> { return context; } /** @internal */ export function getPathnameForRouter( pathname: string, router: Resolver, ): string { // @ts-expect-error: __effectiveBaseUrl is a private property const base = router.__effectiveBaseUrl; return base ? new URL(pathname.replace(/^\//u, ''), base).pathname : pathname; } /** @internal */ export function getMatchedPath(pathItems: ReadonlyArray>): string { return pathItems .map((pathItem) => pathItem.path) .reduce((a, b) => { if (b.length) { return `${a.replace(/\/$/u, '')}/${b.replace(/^\//u, '')}`; } return a; }, ''); } /** @internal */ export function getRoutePath(chain: ReadonlyArray>): string { return getMatchedPath(chain.map((chainItem) => chainItem.route)); } /** @internal */ export type ResolverOnlyContext = Readonly<{ resolver: Router }>; /** @internal */ type PartialRouteContext = Readonly<{ chain?: ReadonlyArray>; hash?: string; params?: IndexedParams; pathname?: string; resolver?: Router; redirectFrom?: string; search?: string; }>; /** @internal */ export function createLocation({ resolver, }: ResolverOnlyContext): RouterLocation; export function createLocation( context: RouteContext, route?: Route, ): RouterLocation; export function createLocation( { chain = [], hash = '', params = {}, pathname = '', redirectFrom, resolver, search = '' }: PartialRouteContext, route?: Route, ): RouterLocation { const routes = chain.map((item) => item.route); return { baseUrl: resolver?.baseUrl ?? '', getUrl: (userParams = {}) => resolver ? getPathnameForRouter( compile(getRoutePath(chain))({ ...params, ...userParams } as Partial>), resolver, ) : '', hash, params, pathname, redirectFrom, route: route ?? (Array.isArray(routes) ? routes.at(-1) : undefined) ?? null, routes, search, searchParams: new URLSearchParams(search), }; } /** @internal */ export function createRedirect( context: RouteContext, pathname: string, ): RedirectResult { const params = { ...context.params }; return { redirect: { from: context.pathname, params, pathname, }, }; } /** @internal */ export function renderElement>( context: RouteContext, element: E, ): E { element.location = createLocation(context); if (context.chain) { const index = context.chain.map((item) => item.route).indexOf(context.route); context.chain[index].element = element; } return element; } /** @internal */ export function maybeCall( callback: ((this: O, ...args: A) => R) | undefined, thisArg: O, ...args: A ): R | undefined { if (typeof callback === 'function') { return callback.apply(thisArg, args); } return undefined; } /** @internal */ export function amend< A extends readonly unknown[], N extends keyof O, O extends object & { [key in N]: (this: O, ...args: A) => ActionResult | undefined }, >(fn: keyof O, obj: O | undefined, ...args: A): (result: ActionResult) => ActionResult | undefined { return (result: ActionResult) => { if (result && isObject(result) && ('cancel' in result || 'redirect' in result)) { return result; } return maybeCall(obj?.[fn], obj!, ...args); }; } /** @internal */ export function processNewChildren( newChildren: Route | ReadonlyArray> | undefined | void, route: Route, ): void { if (!Array.isArray(newChildren) && !isObject(newChildren)) { throw new Error( log( `Incorrect "children" value for the route ${String(route.path)}: expected array or object, but got ${String( newChildren, )}`, ), ); } const children = toArray(newChildren); children.forEach((child) => ensureRoute(child)); route.__children = children; } /** @internal */ export function fireRouterEvent(type: string, detail: unknown): boolean { return !window.dispatchEvent(new CustomEvent(`vaadin-router-${type}`, { cancelable: type === 'go', detail })); } /** @internal */ export function logValue(value: unknown): string { if (typeof value !== 'object') { return String(value); } const [stringType = 'Unknown'] = / (.*)\]$/u.exec(String(value)) ?? []; if (stringType === 'Object' || stringType === 'Array') { return `${stringType} ${JSON.stringify(value)}`; } return stringType; } ================================================ FILE: src/v1-compat.t.ts ================================================ /* eslint-disable max-classes-per-file */ import type { MaybePromise } from './resolver/types.t.js'; import type { Commands, RouteContext, Route, ActionResult, ChildrenCallback, WebComponentInterface, } from './types.t.js'; /** * Action result describing an HTML element to render. * * @deprecated Use `HTMLElement`. */ export type ComponentResult = HTMLElement; /** * Route resolution context object, see {@link RouteContext}. * * @deprecated Use {@link RouteContext}. */ export type Context = RouteContext; /** * Route action callback function, see {@link Route.action}. * * @deprecated Use `NonNullable`. */ export type ActionFn = ( this: Route, context: RouteContext, commands: Commands, ) => MaybePromise; /** * Route children callback function, see {@link ChildrenCallback}. * * @deprecated Use {@link ChildrenCallback}. */ export type ChildrenFn = ChildrenCallback; /** * Web component route interface with {@link onBeforeEnter} callback. * * @deprecated Use {@link WebComponentInterface}. */ export interface BeforeEnterObserver { /** * See {@link WebComponentInterface.onBeforeEnter} */ onBeforeEnter: NonNullable; } /** * Web component route interface with {@link onBeforeLeave} callback. * * @deprecated Use {@link WebComponentInterface}. */ export interface BeforeLeaveObserver { /** * See {@link WebComponentInterface.onBeforeLeave} */ onBeforeLeave: NonNullable; } /** * Web component route interface with {@link onAfterEnter} callback. * * @deprecated Use {@link WebComponentInterface}. */ export interface AfterEnterObserver { /** * See {@link WebComponentInterface.onAfterEnter} */ onAfterEnter: NonNullable; } /** * Web component route interface with {@link onAfterLeave} callback. * * @deprecated Use {@link WebComponentInterface}. */ export interface AfterLeaveObserver { /** * See {@link WebComponentInterface.onAfterLeave} */ onAfterLeave: NonNullable; } ================================================ FILE: test/resolver/LICENSE.txt ================================================ The MIT License Copyright (c) 2015-present Kriasoft. 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. ================================================ FILE: test/resolver/generateUrls.spec.ts ================================================ /** * Universal Router (https://www.kriasoft.com/universal-router/) * * Copyright (c) 2015-present Kriasoft. * * This source code is licensed under the MIT license found in the * LICENSE.txt file in the root directory of this source tree. */ import { expect, use } from '@esm-bundle/chai'; import chaiAsPromised from 'chai-as-promised'; import chaiDom from 'chai-dom'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import generateUrls, { type StringifyQueryParams } from '../../src/resolver/generateUrls.js'; import Resolver from '../../src/resolver/resolver.js'; import '../setup.js'; import type { Route } from '../../src/resolver/types.t.js'; use(chaiDom); use(sinonChai); use(chaiAsPromised); describe('generateUrls(router, options)(routeName, params)', () => { const action = sinon.stub(); it('should throw an error in case of invalid router', () => { // @ts-expect-error: error-throwing test expect(() => generateUrls()).to.throw(TypeError, /An instance of Resolver is expected/u); // @ts-expect-error: error-throwing test expect(() => generateUrls([])).to.throw(TypeError, /An instance of Resolver is expected/u); // @ts-expect-error: error-throwing test expect(() => generateUrls(123)).to.throw(TypeError, /An instance of Resolver is expected/u); // @ts-expect-error: error-throwing test expect(() => generateUrls(null)).to.throw(TypeError, /An instance of Resolver is expected/u); // @ts-expect-error: error-throwing test expect(() => generateUrls(Resolver)).to.throw(TypeError, /An instance of Resolver is expected/u); }); it('should throw an error if no route found', () => { const router = new Resolver({ action, name: 'a', path: '/a' }); const url = generateUrls(router); expect(() => url('hello')).to.throw(Error, /Route "hello" not found/u); router.root.__children = [{ action, name: 'new', path: '/b' }]; expect(url('new')).to.be.equal('/a/b'); }); it('should throw an error if route name is not unique', () => { const router = new Resolver([ { action, name: 'example', path: '/a' }, { action, name: 'example', path: '/b' }, ]); const url = generateUrls(router); expect(() => url('example')).to.throw(Error, /Duplicate route with name "example"/u); }); it('should not throw an error for unique route name', () => { const router = new Resolver([ { action, name: 'example', path: '/a' }, { action, name: 'example', path: '/b' }, { action, name: 'unique', path: '/c' }, ]); const url = generateUrls(router); expect(() => url('unique')).to.not.throw(); }); it('should generate url for named routes', () => { const router1 = new Resolver({ action, name: 'user', path: '/:name' }); const url1 = generateUrls(router1); expect(url1('user', { name: 'koistya' })).to.be.equal('/koistya'); expect(() => url1('user')).to.throw(TypeError, /Expected "name" to be a string/u); const router2 = new Resolver({ action, name: 'user', path: '/user/:id' }); const url2 = generateUrls(router2); expect(url2('user', { id: '123' })).to.be.equal('/user/123'); expect(() => url2('user')).to.throw(TypeError, /Expected "id" to be a string/u); const router3 = new Resolver({ action, path: '/user/:id' }); const url3 = generateUrls(router3); expect(() => url3('user')).to.throw(Error, /Route "user" not found/u); }); it('should generate url for nested routes', () => { const resolver = new Resolver({ __children: [ { __children: [ { name: 'c', path: '/c/:y', }, { path: '/d' }, { path: '/e' }, ], name: 'b', path: '/b/:x', }, ], name: 'a', path: '', }); const url = generateUrls(resolver); expect(url('a')).to.be.equal('/'); expect(url('b', { x: '123' })).to.be.equal('/b/123'); expect(url('c', { x: 'i', y: 'j' })).to.be.equal('/b/i/c/j'); // TODO(platosha): Re-enable assergin `routesByName` when the API is exposed // // the .keys assertion does not work with ES6 Maps until chai 4.x // let routesByName = Array.from(router.routesByName.keys()); // expect(routesByName).to.have.all.members(['a', 'b', 'c']); (resolver.root.__children as Array>).push({ name: 'new', path: '/new', children: [] }); expect(url('new')).to.be.equal('/new'); // TODO(platosha): Re-enable assergin `routesByName` when the API is exposed // // the .keys assertion does not work with ES6 Maps until chai 4.x // routesByName = Array.from(router.routesByName.keys()); // expect(routesByName).to.have.all.members(['a', 'b', 'c', 'new']); }); it('should respect baseUrl', () => { // NOTE(platosha): the baseUrl support is only available in Vaadin.Router, // the generateUrls method should return clean urls without the base then. const options = { baseUrl: '/base/' }; const router1 = new Resolver({ name: 'home', path: '', children: [] }, options); const url1 = generateUrls(router1); expect(url1('home')).to.be.equal('/'); const router2 = new Resolver({ name: 'post', path: '/post/:id', children: [] }, options); const url2 = generateUrls(router2); expect(url2('post', { id: '12', x: 'y' })).to.be.equal('/post/12'); const router3 = new Resolver( { __children: [ { name: 'b', path: '', }, { __children: [ { name: 'd', path: '/d/:y', }, ], name: 'c', path: '/c/:x', }, ], name: 'a', path: '', }, options, ); const url3 = generateUrls(router3); expect(url3('a')).to.be.equal('/'); expect(url3('b')).to.be.equal('/'); expect(url3('c', { x: 'x' })).to.be.equal('/c/x'); expect(url3('d', { x: 'x', y: 'y' })).to.be.equal('/c/x/d/y'); (router3.root.__children as Array>).push({ name: 'new', path: '/new' }); expect(url3('new')).to.be.equal('/new'); }); it('should generate url with trailing slash', () => { const routes: readonly Route[] = [ { name: 'a', path: '/', children: [] }, { __children: [ { name: 'b', path: '/', children: [] }, { name: 'c', path: '/child/', children: [] }, ], path: '/parent', children: [], }, ]; const router = new Resolver(routes); const url = generateUrls(router); expect(url('a')).to.be.equal('/'); expect(url('b')).to.be.equal('/parent/'); expect(url('c')).to.be.equal('/parent/child/'); // NOTE(platosha): the baseUrl support is only available in Vaadin.Router, // the generateUrls method should return clean urls without the base then. const baseRouter = new Resolver(routes, { baseUrl: '/base/' }); const baseUrl = generateUrls(baseRouter); expect(baseUrl('a')).to.be.equal('/'); expect(baseUrl('b')).to.be.equal('/parent/'); expect(baseUrl('c')).to.be.equal('/parent/child/'); }); it('should encode params', () => { const router = new Resolver({ name: 'user', path: '/:user', children: [] }); const url = generateUrls(router); const prettyUrl = generateUrls(router, { encode(str) { return typeof str === 'string' ? encodeURI(str).replace(/[/?#]/gu, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) : ''; }, }); expect(url('user', { user: '#$&+,/:;=?@' })).to.be.equal('/%23%24%26%2B%2C%2F%3A%3B%3D%3F%40'); expect(prettyUrl('user', { user: '#$&+,/:;=?@' })).to.be.equal('/%23$&+,%2F:;=%3F@'); }); it('should stringify query params (1)', () => { const router = new Resolver({ name: 'user', path: '/:user', children: [] }); const stringifyQueryParams = sinon.spy(() => 'qs'); const url = generateUrls(router, { stringifyQueryParams }); expect(url('user', { busy: '1', user: 'tj' })).to.be.equal('/tj?qs'); expect(stringifyQueryParams.calledOnce).to.be.true; expect(stringifyQueryParams.args[0][0]).to.be.deep.equal({ busy: '1' }); }); it('should stringify query params (2)', () => { const router = new Resolver({ name: 'user', path: '/user/:username', children: [] }); const stringifyQueryParams = sinon.spy(() => ''); const url = generateUrls(router, { stringifyQueryParams }); expect(url('user', { busy: '1', username: 'tj' })).to.be.equal('/user/tj'); expect(stringifyQueryParams.calledOnce).to.be.true; expect(stringifyQueryParams.args[0][0]).to.be.deep.equal({ busy: '1' }); }); it('should stringify query params (3)', () => { const router = new Resolver({ name: 'me', path: '/me', children: [] }); const stringifyQueryParams = sinon.spy(() => '?x=i&y=j&z=k'); const url = generateUrls(router, { stringifyQueryParams }); expect(url('me', { x: 'i', y: 'j', z: 'k' })).to.be.equal('/me?x=i&y=j&z=k'); expect(stringifyQueryParams.calledOnce).to.be.true; expect(stringifyQueryParams.args[0][0]).to.be.deep.equal({ x: 'i', y: 'j', z: 'k' }); }); }); ================================================ FILE: test/resolver/matchPath.spec.ts ================================================ /** * Universal Router (https://www.kriasoft.com/universal-router/) * * Copyright (c) 2015-present Kriasoft. * * This source code is licensed under the MIT license found in the * LICENSE.txt file in the root directory of this source tree. */ import { expect, use } from '@esm-bundle/chai'; import chaiAsPromised from 'chai-as-promised'; import chaiDom from 'chai-dom'; import sinonChai from 'sinon-chai'; import matchPath from '../../src/resolver/matchPath.js'; import '../setup.js'; use(chaiDom); use(sinonChai); use(chaiAsPromised); describe('matchPath', () => { describe('negative matches (should return null)', () => { describe('the empty route ("")', () => { it('should not match anything but the empty path if _exact_', () => { expect(matchPath('', '/', true)).to.be.null; expect(matchPath('', '/a', true)).to.be.null; expect(matchPath('', 'a', true)).to.be.null; }); }); describe('the root route ("/")', () => { it('should not match the empty path', () => { const result = matchPath('/', ''); expect(result).to.be.null; }); it('should not match other absolute paths if _exact_', () => { const result = matchPath('/', '/a', true); expect(result).to.be.null; }); it('should not match relative paths', () => { const result = matchPath('/', 'a'); expect(result).to.be.null; }); }); describe('a non-root absolute route ("/a/b/c")', () => { it('should not match if the path and the route are completely different', () => { const result = matchPath('/a', 'x'); expect(result).to.be.null; }); it('should not match if the path is only a prefix of the route', () => { const result = matchPath('/a', '/'); expect(result).to.be.null; }); it('should not match if the route and the path have only a common segment', () => { const result = matchPath('/a/b', '/a/c'); expect(result).to.be.null; }); it('should not match if the route and the path have only a common prefix', () => { const result = matchPath('/a', '/ab'); expect(result).to.be.null; }); it('should not match if the route does have a trailing slash and the path does not', () => { const result = matchPath('/a/', '/a'); expect(result).to.be.null; }); describe('exact', () => { it('should not match if the route is only a prefix in a multi-segment path', () => { const result = matchPath('/a', '/a/b', true); expect(result).to.be.null; }); }); }); describe('a non-empty relative route ("a/b/c")', () => { it('should not match if the path and the route are completely different', () => { const result = matchPath('aa', 'x'); expect(result).to.be.null; }); it('should not match if the path is only a prefix of the route', () => { const result = matchPath('aa', 'a'); expect(result).to.be.null; }); it('should not match if the route and the path have only a common segment', () => { const result = matchPath('a/b', 'a/c'); expect(result).to.be.null; }); it('should not match if the route and the path have only a common prefix', () => { const result = matchPath('a', 'ab'); expect(result).to.be.null; }); it('should not match if the route does have a trailing slash and the path does not', () => { const result = matchPath('a/', 'a'); expect(result).to.be.null; }); describe('exact', () => { it('should not match if the route is only a prefix in a multi-segment path', () => { const result = matchPath('a', 'a/b', true); expect(result).to.be.null; }); }); }); }); describe('positive matches (should return a match object)', () => { describe('the empty route ("")', () => { it('should match the empty path', () => { const result = matchPath('', ''); expect(result).to.have.property('path', ''); }); it('should match the root path', () => { const result = matchPath('', '/'); expect(result).to.have.property('path', ''); }); it('should match absolute paths', () => { const result = matchPath('', '/a'); expect(result).to.have.property('path', ''); }); it('should match relative paths', () => { const result = matchPath('', 'a'); expect(result).to.have.property('path', ''); }); it('should match multi-segment paths', () => { const result = matchPath('', 'a/b'); expect(result).to.have.property('path', ''); }); }); describe('the root route ("/")', () => { it('should match the root path', () => { const result = matchPath('/', '/'); expect(result).to.have.property('path', '/'); }); it('should match simple absolute paths', () => { const result = matchPath('/', '/a'); expect(result).to.have.property('path', '/'); }); it('should match multi-segment absolute paths', () => { const result = matchPath('/', '/a/b'); expect(result).to.have.property('path', '/'); }); }); describe('a non-root absolute route ("/a/b/c")', () => { it('should match if the route exactly matches the path', () => { const result = matchPath('/a', '/a'); expect(result).to.have.property('path', '/a'); }); it('should match if the route is a prefix of the path', () => { const result = matchPath('/a', '/a/b'); expect(result).to.have.property('path', '/a'); }); it('should match if the route does not have a trailing slash and the path does', () => { const result = matchPath('/a', '/a/'); expect(result).to.have.property('path', '/a/'); }); it('should match if both the route and the path do have trailing slashes', () => { const result = matchPath('/a/', '/a/'); expect(result).to.have.property('path', '/a/'); }); }); describe('a non-empty relative route ("a/b/c")', () => { it('should match if the route exactly matches the path', () => { const result = matchPath('a', 'a'); expect(result).to.have.property('path', 'a'); }); it('should match if the route is a prefix of the path', () => { const result = matchPath('a', 'a/b'); expect(result).to.have.property('path', 'a'); }); it('should match if the route does not have a trailing slash and the path does', () => { const result = matchPath('a/b/c', 'a/b/c/'); expect(result).to.have.property('path', 'a/b/c/'); }); it('should match if both the route and the path do have trailing slashes', () => { const result = matchPath('a/b/c/', 'a/b/c/'); expect(result).to.have.property('path', 'a/b/c/'); }); }); }); describe('the match object', () => { it('should contain keys and params of the route if the route has some params', () => { const result = matchPath('/:a/:b', '/1/2'); expect(result).to.be.ok; expect(result).to.have.property('path', '/1/2'); expect(result).to.have.property('keys').and.be.an('array').lengthOf(2); expect(result).to.have.property('params').and.be.deep.equal({ a: '1', b: '2' }); }); it('should contain empty keys and params if the route has no params', () => { const result = matchPath('', ''); expect(result).to.have.property('keys').and.be.an('array').lengthOf(0); expect(result).to.have.property('params').and.be.an('object').and.be.deep.equal({}); }); it('should preserve the provided keys and params if the route has no params', () => { const { keys, params } = matchPath('/:x', '/y')!; const result = matchPath('/a', '/a', false, keys, params); expect(result).to.have.property('keys').and.be.deep.equal(keys); expect(result).to.have.property('params').and.be.deep.equal(params); }); it('should amend the provided keys and params if the route has some params', () => { const { keys, params } = matchPath('/:x', '/y')!; const result = matchPath('/:a/:b?', '/1', false, keys, params); expect(result).to.have.property('path', '/1'); expect(result).to.have.property('keys').and.be.an('array').lengthOf(3); expect(result?.keys[0]).to.be.deep.equal(keys[0]); expect(result).to.have.property('params').and.be.deep.equal({ a: '1', b: undefined, x: 'y' }); }); it('should override the provided param value with the route param of the same name', () => { const { keys, params } = matchPath('/:b', '/0')!; const result = matchPath('/:a/:b?', '/1/2', false, keys, params); expect(result).to.have.property('path', '/1/2'); expect(result).to.have.property('params').and.be.deep.equal({ a: '1', b: '2' }); }); it('should not override the provided param value with undefined', () => { const { keys, params } = matchPath('/:b', '/0')!; const result = matchPath('/:a/:b?', '/1', false, keys, params); expect(result).to.have.property('path', '/1'); expect(result).to.have.property('params').and.be.deep.equal({ a: '1', b: '0' }); }); it.skip('should override the provided param key with the route param of the same name', () => { const { keys, params } = matchPath('/:b', '/0')!; const result = matchPath('/:a/:b?', '/1/2', false, keys, params); expect(result).to.have.property('path', '/1/2'); expect(result).to.have.property('keys').and.be.an('array').lengthOf(2); }); it.skip('should not override the provided param key with undefined', () => { const { keys, params } = matchPath('/:b', '/0')!; const result = matchPath('/:a/:b?', '/1', false, keys, params); expect(result).to.have.property('path', '/1'); expect(result).to.have.property('keys').and.be.an('array').lengthOf(2); expect(result?.keys[0]).to.be.deep.equal(keys[0]); }); }); describe('the route pattern', () => { it('should allow literal parenthesis', () => { const result = matchPath('/:user\\(:op\\)', '/tj(edit)'); expect(result).to.have.property('path', '/tj(edit)'); expect(result).to.have.property('keys').and.be.an('array').lengthOf(2); expect(result).to.have.property('params').and.be.deep.equal({ op: 'edit', user: 'tj' }); }); it('should allow unnamed capturing groups', () => { const result1 = matchPath('/user(s)?/:user/:op', '/users/tj/edit'); const result2 = matchPath('/user(s)?/:user/:op', '/user/tj/edit'); expect(result1).to.have.property('keys').and.be.an('array').lengthOf(3); expect(result1).to.have.property('params').and.be.deep.equal({ 0: 's', op: 'edit', user: 'tj' }); expect(result2).to.have.property('keys').and.be.an('array').lengthOf(3); expect(result2).to.have.property('params').and.be.deep.equal({ 0: undefined, op: 'edit', user: 'tj' }); }); it('should support repeat parameters (1)', () => { const result = matchPath('/:a*', '/1/2/3'); expect(result).to.have.property('keys').and.be.an('array').lengthOf(1); expect(result) .to.have.property('params') .and.be.deep.equal({ a: ['1', '2', '3'] }); }); it('should support repeat parameters (2)', () => { const result = matchPath('/:a*', '/1'); expect(result).to.have.property('keys').and.be.an('array').lengthOf(1); expect(result) .to.have.property('params') .and.be.deep.equal({ a: ['1'] }); }); it('should support repeat parameters (3)', () => { const result = matchPath('/:a*', '/'); expect(result).to.have.property('keys').and.be.an('array').lengthOf(1); expect(result).to.have.property('params').and.be.deep.equal({ a: [] }); }); }); describe('params decoding', () => { it('should decode URI-encoded params correctly', () => { const result = matchPath('/:a/:b/:c', '/%2F/%3A/caf%C3%A9'); expect(result).to.have.property('params').and.be.deep.equal({ a: '/', b: ':', c: 'café' }); }); it('should not throw an error for malformed URI params', () => { const fn = () => matchPath('/:a', '/%AF'); expect(fn).to.not.throw(); expect(fn()).to.have.property('params').and.be.deep.equal({ a: '%AF' }); }); it('should decode repeat parameters correctly', () => { const fn = () => matchPath('/:a+', '/x%2Fy/z/%20/%AF'); expect(fn).to.not.throw(); expect(fn()) .to.have.property('params') .and.be.deep.equal({ a: ['x/y', 'z', ' ', '%AF'] }); }); }); describe.skip('array of paths', () => { it('should match to an array of paths', () => { // @ts-expect-error: currently unsupported feature const result = matchPath({ path: ['/e', '/f'] }, '/f'); expect(result).to.be.deep.equal({ keys: [], params: {}, path: '/f' }); }); it('should not override existing param with undefined', () => { // @ts-expect-error: currently unsupported feature const fn = () => matchPath({ path: ['/a/:c', '/b/:c'] }, '/a/x'); expect(fn).to.not.throw(); expect(fn()).to.have.property('params').and.be.deep.equal({ c: 'x' }); }); }); }); ================================================ FILE: test/resolver/matchRoute.spec.ts ================================================ /** * Universal Router (https://www.kriasoft.com/universal-router/) * * Copyright (c) 2015-present Kriasoft. * * This source code is licensed under the MIT license found in the * LICENSE.txt file in the root directory of this source tree. */ import { expect, use } from '@esm-bundle/chai'; import chaiDom from 'chai-dom'; import sinonChai from 'sinon-chai'; import matchRoute from '../../src/resolver/matchRoute.js'; import '../setup.js'; import type { Route } from '../../src/resolver/types.t.js'; use(chaiDom); use(sinonChai); function toArray(iter: Iterator): readonly T[] { const arr = []; let res = iter.next(); while (!res.done) { arr.push(res.value); res = iter.next(); } return arr; } const dummyAction = () => undefined; describe('matchRoute(route, pathname)', () => { it('should return a valid iterator', () => { const route: Route = { path: '/a', action: dummyAction, }; const result = matchRoute(route, '/a'); expect(result).to.be.an('object').and.not.be.null; expect(result).to.have.property('next').that.is.a('function'); const item = result.next(); expect(Boolean(item.done)).to.be.false; expect(item).to.have.property('value').that.is.an('object').and.is.not.null; const item2 = result.next(); expect(item2).to.have.property('done', true); }); it('should yield well-formed match objects', () => { const route: Route = { path: '/a', action: dummyAction, }; const match = matchRoute(route, '/a').next().value; expect(match).to.have.property('route').that.is.an('object').and.is.not.null; expect(match).to.have.property('keys').that.is.an('array'); expect(match).to.have.property('params').that.is.an('object').and.is.not.null; expect(match).to.have.property('path').that.is.an('string').and.is.not.null; }); it('should treat undefined route path as ""', () => { // @ts-expect-error: Testing JS-specific behavior const result = toArray(matchRoute({ path: undefined, action: dummyAction }, '')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', undefined); }); describe('no matches', () => { it('should not match a route if it does not match the path', () => { const route: Route = { path: '/a', action: dummyAction, }; const result = toArray(matchRoute(route, '/b')); expect(result).to.have.lengthOf(0); }); it('should not match a child route that would have matched if it was on the root level', () => { const route: Route = { path: '/a', children: [{ path: '/b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/b')); expect(result).to.have.lengthOf(0); }); it('should not match a route sequence which--when literally joined--matches the path', () => { const route: Route = { path: 'a', children: [{ path: 'b', action: dummyAction }], }; const result = toArray(matchRoute(route, 'ab')); expect(result).to.have.lengthOf(0); }); }); describe('matches the root of the routes tree', () => { it('should match a route without children if it matches the path exactly', () => { const route: Route = { path: '/a', action: dummyAction, }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', '/a'); }); it('should not match a route without children if it matches only a prefix of the path', () => { const route: Route = { path: '/a', action: () => {}, }; const result = toArray(matchRoute(route, '/a/x')); expect(result).to.have.lengthOf(0); }); it('should match a route with children if it matches the path exactly', () => { const route: Route = { path: '/a', children: [ { path: '/b', action: dummyAction }, { path: '/c', action: dummyAction }, { path: '/d', action: dummyAction }, ], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', '/a'); }); it('should match a route with children if it matches only a prefix of the path', () => { const route: Route = { path: '/a', children: [ { path: '/b', action: dummyAction }, { path: '/c', action: dummyAction }, { path: '/d', action: dummyAction }, ], }; const result = toArray(matchRoute(route, '/a/x')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', '/a'); }); it('should use prefix-matching if the children property is truthy but is not an array of routes', () => { const route: Route = { path: '/a', children: () => [{ path: '/b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/x')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', '/a'); }); it('should match a multi-segment route without children', () => { const route: Route = { path: '/a/b', action: dummyAction, }; const result = toArray(matchRoute(route, '/a/b')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', '/a/b'); }); }); describe('matches child routes', () => { it('should match both the parent and one child route (parent first) - single child', () => { const route: Route = { path: '/a', children: [{ path: '/b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/b')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', '/b'); }); it('should match both the parent and one child route (parent first) - several children', () => { const route: Route = { path: '/a', children: [ { path: '/b', action: dummyAction }, { path: '/c', action: dummyAction }, { path: '/d', action: dummyAction }, ], }; const result = toArray(matchRoute(route, '/a/d')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', '/d'); }); }); describe('matches sibling routes', () => { it('should match all sibling routes in their definition order', () => { const route: Route = { path: '/a', children: [ { path: '/b', action: dummyAction }, { path: '/:id', action: dummyAction }, ], }; const result = toArray(matchRoute(route, '/a/b')); expect(result).to.have.lengthOf(3); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', '/b'); expect(result[2]).to.have.nested.property('route.path', '/:id'); }); it('should match both a multi-segment no-children route and a route with children', () => { const route: Route = { path: '/a', children: [ { path: '/b/c', action: dummyAction }, { path: '/b', children: [{ path: '/c', action: dummyAction }], }, ], }; const result = toArray(matchRoute(route, '/a/b/c')); expect(result).to.have.lengthOf(4); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', '/b/c'); expect(result[2]).to.have.nested.property('route.path', '/b'); expect(result[3]).to.have.nested.property('route.path', '/c'); }); it('should continue matching on the parent level after siblings are checked', () => { const route: Route = { path: '/a', children: [ { path: '/b', children: [{ path: '/c', action: dummyAction }], }, { path: '/b/c', action: dummyAction }, ], }; const result = toArray(matchRoute(route, '/a/b/c')); expect(result).to.have.lengthOf(4); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', '/b'); expect(result[2]).to.have.nested.property('route.path', '/c'); expect(result[3]).to.have.nested.property('route.path', '/b/c'); }); }); describe('leading and trailing "/" in the route path', () => { it('should match a relative route to a relative path', () => { const route: Route = { path: 'a', action: dummyAction, }; const result = toArray(matchRoute(route, 'a')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', 'a'); }); it('should not match an absolute route to a relative path', () => { const route: Route = { path: '/a', action: dummyAction, }; const result = toArray(matchRoute(route, 'a')); expect(result).to.have.lengthOf(0); }); it('should not match a relative route to an absolute path', () => { const route: Route = { path: 'a', action: dummyAction, }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(0); }); it('should match a route with a trailing "/" and no children to a path with a trailing "/"', () => { const route: Route = { path: 'a/', action: dummyAction, }; const result = toArray(matchRoute(route, 'a/')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', 'a/'); }); it('should match a route with a trailing "/" and some children to a path with a trailing "/"', () => { const route: Route = { path: 'a/', children: [ { path: '/b', action: dummyAction }, { path: '/c', action: dummyAction }, { path: '/d', action: dummyAction }, ], }; const result = toArray(matchRoute(route, 'a/')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', 'a/'); }); it('should match a route with a trailing "/" and some children to a path with more segments', () => { const route: Route = { path: 'a/', children: [ { path: '/b', action: dummyAction }, { path: '/c', action: dummyAction }, { path: '/d', action: dummyAction }, ], }; const result = toArray(matchRoute(route, 'a/x')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', 'a/'); }); it('should not match a route with a trailing "/" to a path without a trailing "/"', () => { const route: Route = { path: '/a/', action: dummyAction, }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(0); }); it('should match a route without a trailing "/" to a path with a trailing "/"', () => { const route: Route = { path: '/a', action: dummyAction, }; const result = toArray(matchRoute(route, '/a/')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', '/a'); }); it('should match child routes without the leading "/"', () => { const route: Route = { path: '/a', children: [{ path: 'b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/b')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', 'b'); }); it('should match parent routes with a trailing "/" and child routes with a leading "/"', () => { const route: Route = { path: '/a/', children: [{ path: '/b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/b')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a/'); expect(result[1]).to.have.nested.property('route.path', '/b'); }); it('should match parent routes with a trailing "/" and child routes without a leading "/"', () => { const route: Route = { path: '/a/', children: [{ path: 'b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/b')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a/'); expect(result[1]).to.have.nested.property('route.path', 'b'); }); it('should match deep child routes without a leading "/"', () => { const route: Route = { path: '/a', children: [ { path: 'b', children: [{ path: 'c', action: dummyAction }], }, ], }; const result = toArray(matchRoute(route, '/a/b/c')); expect(result).to.have.lengthOf(3); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', 'b'); expect(result[2]).to.have.nested.property('route.path', 'c'); }); it('should match child routes if the path has a trailing "/"', () => { const route: Route = { path: '/a', children: [{ path: 'b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/b/')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', 'b'); }); }); describe('"" and "/" routes', () => { it('should not match a "" route without children to any other path than ""', () => { expect(toArray(matchRoute({ path: '', action: dummyAction }, '/'))).to.have.lengthOf(0); expect(toArray(matchRoute({ path: '', action: dummyAction }, '/a'))).to.have.lengthOf(0); expect(toArray(matchRoute({ path: '', action: dummyAction }, 'a'))).to.have.lengthOf(0); }); it('should match a "" route with children to an absolute path', () => { const route: Route = { path: '', children: [ { path: '/b', action: dummyAction }, { path: '/c', action: dummyAction }, { path: '/d', action: dummyAction }, ], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', ''); }); it('should match a "" route with children to an relative path', () => { const route: Route = { path: '', children: [ { path: '/b', action: dummyAction }, { path: '/c', action: dummyAction }, { path: '/d', action: dummyAction }, ], }; const result = toArray(matchRoute(route, 'a')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', ''); }); it('should match absolute children of a "" route to an absolute path', () => { const route: Route = { path: '', children: [{ path: '/a', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', ''); expect(result[1]).to.have.nested.property('route.path', '/a'); }); it('should match relative children of a "" route to a relative path', () => { const route: Route = { path: '', children: [{ path: 'a', action: dummyAction }], }; const result = toArray(matchRoute(route, 'a')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', ''); expect(result[1]).to.have.nested.property('route.path', 'a'); }); it('should not match absolute children of a "" route to an relative path', () => { const route: Route = { path: '', children: [{ path: '/a', action: dummyAction }], }; const result = toArray(matchRoute(route, 'a')); expect(result).to.have.lengthOf(1); }); it('should not match relative children of a "" route to an absolute path', () => { const route: Route = { path: '', children: [{ path: 'a', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(1); }); it('should match a child "" route if the path does not have a trailing "/"', () => { const route: Route = { path: '/a', children: [{ path: '', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', ''); }); it('should match a child "" route if the path does have a trailing "/"', () => { const route: Route = { path: '/a', children: [{ path: '', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', ''); }); it('should match both the parent and the child "" routes', () => { const route: Route = { path: '', name: 'parent', children: [ { path: '', name: 'child', children: [{ path: 'a', action: dummyAction }], }, ], }; const result = toArray(matchRoute(route, 'a')); expect(result).to.have.lengthOf(3); expect(result[0]).to.have.nested.property('route.name', 'parent'); expect(result[1]).to.have.nested.property('route.name', 'child'); expect(result[2]).to.have.nested.property('route.path', 'a'); }); it('should match several nested "" routes', () => { const route: Route = { path: '', name: 'level-1', children: [ { path: '', name: 'level-2', children: [ { path: '', name: 'level-3', children: [ { path: '', name: 'level-4', action: dummyAction }, { path: '/a', action: dummyAction }, ], }, ], }, ], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(4); expect(result[0]).to.have.nested.property('route.name', 'level-1'); expect(result[1]).to.have.nested.property('route.name', 'level-2'); expect(result[2]).to.have.nested.property('route.name', 'level-3'); expect(result[3]).to.have.nested.property('route.path', '/a'); }); it('should not match a "/" route without children to any other path than "/"', () => { expect(toArray(matchRoute({ path: '/', action: dummyAction }, ''))).to.have.lengthOf(0); expect(toArray(matchRoute({ path: '/', action: dummyAction }, '/a'))).to.have.lengthOf(0); expect(toArray(matchRoute({ path: '/', action: dummyAction }, 'a'))).to.have.lengthOf(0); }); it('should match a "/" route with children to an absolute path', () => { const route: Route = { path: '/', children: [ { path: '/b', action: dummyAction }, { path: '/c', action: dummyAction }, { path: '/d', action: dummyAction }, ], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(1); expect(result[0]).to.have.nested.property('route.path', '/'); }); it('should not match a "/" route with children to a relative path', () => { const route: Route = { path: '/', children: [{ path: 'a', action: dummyAction }], }; const result = toArray(matchRoute(route, 'a')); expect(result).to.have.lengthOf(0); }); it('should match (absolute) children of a "/" route', () => { const route: Route = { path: '/', children: [{ path: '/a', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/'); expect(result[1]).to.have.nested.property('route.path', '/a'); }); it('should match (relative) children of a "/" route', () => { const route: Route = { path: '/', children: [{ path: 'a', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/'); expect(result[1]).to.have.nested.property('route.path', 'a'); }); it('should match a child "/" route if the path does not have a trailing "/"', () => { const route: Route = { path: '/a', children: [{ path: '/', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', '/'); }); it('should match a child "/" route if the path does have a trailing "/"', () => { const route: Route = { path: '/a', children: [{ path: '/', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.path', '/a'); expect(result[1]).to.have.nested.property('route.path', '/'); }); it('should match both the parent and the child "/" routes', () => { const route: Route = { path: '/', name: 'parent', children: [ { path: '/', name: 'child', children: [{ path: 'a', action: dummyAction }], }, ], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(3); expect(result[0]).to.have.nested.property('route.name', 'parent'); expect(result[1]).to.have.nested.property('route.name', 'child'); expect(result[2]).to.have.nested.property('route.path', 'a'); }); it('should match several nested "/" routes', () => { const route: Route = { path: '/', name: 'level-1', children: [ { path: '/', name: 'level-2', children: [ { path: '/', name: 'level-3', children: [ { path: '/', name: 'level-4', action: dummyAction }, { path: '/a', action: dummyAction }, ], }, ], }, ], }; const result = toArray(matchRoute(route, '/a')); expect(result).to.have.lengthOf(4); expect(result[0]).to.have.nested.property('route.name', 'level-1'); expect(result[1]).to.have.nested.property('route.name', 'level-2'); expect(result[2]).to.have.nested.property('route.name', 'level-3'); expect(result[3]).to.have.nested.property('route.path', '/a'); }); it('should not match a deep child with a leading "/" if all parents are "" and the path is relative', () => { const route: Route = { path: '', name: 'parent', children: [ { path: '', name: 'child', children: [{ path: '/a', action: dummyAction }], }, ], }; const result = toArray(matchRoute(route, 'a')); expect(result).to.have.lengthOf(2); expect(result[0]).to.have.nested.property('route.name', 'parent'); expect(result[1]).to.have.nested.property('route.name', 'child'); }); it('should match a deep child without a leading "/" if all parents are "" and the path is relative', () => { const route: Route = { path: '', name: 'parent', children: [ { path: '', name: 'child', children: [{ path: 'a', action: dummyAction }], }, ], }; const result = toArray(matchRoute(route, 'a')); expect(result).to.have.lengthOf(3); expect(result[0]).to.have.nested.property('route.name', 'parent'); expect(result[1]).to.have.nested.property('route.name', 'child'); expect(result[2]).to.have.nested.property('route.path', 'a'); }); }); describe('keys and params in the match object', () => { it('should contain the keys and params of the matched route', () => { const route: Route = { path: '/a/:b', action: dummyAction, }; const result = toArray(matchRoute(route, '/a/1')); expect(result[0]).to.have.property('keys').that.is.an('array').and.is.lengthOf(1); expect(result[0]).to.have.property('params').that.is.deep.equal({ b: '1' }); }); it('should contain the keys and params of the parent route', () => { const route: Route = { path: '/a/:b', children: [{ path: '/:c', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/1/2')); expect(result[1]).to.have.property('keys').that.is.an('array').and.is.lengthOf(2); expect(result[1]).to.have.property('params').that.is.deep.equal({ b: '1', c: '2' }); }); it('should be empty if neither the matched route nor its parents have any params', () => { const route: Route = { path: '/a', children: [{ path: '/b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/b')); expect(result[0]).to.have.property('keys').that.is.an('array').and.is.lengthOf(0); expect(result[0]).to.have.property('params').that.is.deep.equal({}); expect(result[1]).to.have.property('keys').that.is.an('array').and.is.lengthOf(0); expect(result[1]).to.have.property('params').that.is.deep.equal({}); }); it('should not contain the keys and params of the child routes', () => { const route: Route = { path: '/a/:b', children: [{ path: '/:c', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/1/2')); expect(result[0]).to.have.property('keys').that.is.an('array').and.is.lengthOf(1); expect(result[0]).to.have.property('params').that.is.deep.equal({ b: '1' }); }); it('should not contain the keys and params of the sibling routes', () => { const route: Route = { path: '/a/:b', children: [ { path: '/:c', action: dummyAction }, { path: '/2', action: dummyAction }, ], }; const result = toArray(matchRoute(route, '/a/1/2')); expect(result[2]).to.have.property('keys').that.is.an('array').and.is.lengthOf(1); expect(result[2]).to.have.property('params').that.is.deep.equal({ b: '1' }); }); it('should override a parent route param value with that of a child route if the param names collide', () => { const route: Route = { path: '/a/:b', children: [{ path: '/:b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/1/2')); expect(result[1]).to.have.property('params').that.is.deep.equal({ b: '2' }); }); it('should not override a parent route param value with `undefined` (for an optional child param)', () => { const route: Route = { path: '/a/:b', children: [{ path: '/:b?', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/1')); expect(result[1]).to.have.property('params').that.is.deep.equal({ b: '1' }); }); it('should not override a parent route param value in the parent match', () => { const route: Route = { path: '/a/:b', children: [{ path: '/:b', action: dummyAction }], }; const result = toArray(matchRoute(route, '/a/1')); expect(result[0]).to.have.property('params').that.is.deep.equal({ b: '1' }); }); }); }); ================================================ FILE: test/resolver/resolver.spec.ts ================================================ /** * Universal resolver (https://www.kriasoft.com/universal-resolver/) * * Copyright (c) 2015-present Kriasoft. * * This source code is licensed under the MIT license found in the * LICENSE.txt file in the root directory of this source tree. */ import { expect, use } from '@esm-bundle/chai'; import chaiAsPromised from 'chai-as-promised'; import chaiDom from 'chai-dom'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import type { EmptyObject } from 'type-fest'; import Resolver, { ResolutionError } from '../../src/resolver/resolver.js'; import '../setup.js'; import type { Route, RouteContext } from '../../src/resolver/types.t.js'; use(chaiDom); use(sinonChai); use(chaiAsPromised); describe('Resolver', () => { describe('new Resolver(routes, options)', () => { it('should throw an error in case of invalid routes', () => { // @ts-expect-error: error-throwing test expect(() => new Resolver()).to.throw(TypeError, /Invalid routes/u); // @ts-expect-error: error-throwing test expect(() => new Resolver(12)).to.throw(TypeError, /Invalid routes/u); // @ts-expect-error: error-throwing test expect(() => new Resolver(null)).to.throw(TypeError, /Invalid routes/u); }); it('should support custom resolve option for declarative routes', async () => { type CustomResolveOption = Readonly<{ component?: string; }>; const resolveRoute = sinon.spy((context: RouteContext) => context.route.component); const action = sinon.stub(); const resolver = new Resolver( { action, children: [ { action, component: undefined, path: '/:b' }, { action, component: 'c', path: '/c' }, { action, component: 'd', path: '/d' }, ], path: '/a', }, { resolveRoute }, ); const context = await resolver.resolve('/a/c'); expect(resolveRoute.calledThrice).to.be.true; expect(action.called).to.be.false; expect((context as RouteContext).result).to.be.equal('c'); }); it('should support custom error handler option', async () => { const errorResult = document.createElement('error-result'); const errorHandler = sinon.spy(() => errorResult); const resolver = new Resolver([], { errorHandler }); const context = await resolver.resolve('/'); expect(context).to.have.property('result').that.equals(errorResult); expect(errorHandler.calledOnce).to.be.true; const error = errorHandler.firstCall.firstArg; expect(error).to.be.an('error'); expect(error) .to.have.property('message') .that.matches(/Page not found/u); expect(error).to.have.property('code', 404); expect(error).to.have.property('context').that.includes({ pathname: '/', resolver }); }); it('should handle route errors', async () => { const errorResult = document.createElement('error-result'); const errorHandler = sinon.spy(() => errorResult); const route = { action: () => { throw new Error('custom'); }, path: '/', }; const resolver = new Resolver(route, { errorHandler }); const context = await resolver.resolve('/'); expect(context).to.have.property('result').that.equals(errorResult); expect(errorHandler).to.be.calledOnce; const error: ResolutionError = errorHandler.firstCall.firstArg; expect(error).to.be.instanceof(ResolutionError); expect(error.cause).to.be.an('error'); expect(error.cause).to.have.property('message').that.equals('custom'); expect(error).to.have.property('code', 500); expect(error).to.have.property('context').that.includes({ pathname: '/', resolver }); expect(error).to.have.nested.property('context.route').that.includes(route); }); }); describe('router JS API', () => { type RouteWithComponent = Readonly<{ component: string; }>; it('should have a getter for the routes config', () => { const router = new Resolver([]); const actual = router.getRoutes(); expect(actual).to.be.an('array').that.is.empty; }); it('should have a setter for the routes config', () => { const router = new Resolver([]); router.setRoutes([{ component: 'x-home-view', path: '/' }]); const actual = router.getRoutes(); expect(actual).to.be.an('array').that.has.lengthOf(1); expect(actual[0]).to.have.property('path', '/'); expect(actual[0]).to.have.property('component', 'x-home-view'); }); it('should have a method for adding routes', () => { const router = new Resolver([]); // @ts-expect-error: testing protected method const newRoutes = router.addRoutes([{ component: 'x-home-view', path: '/' }]); const actual = router.getRoutes(); expect(newRoutes).to.deep.equal(actual); expect(actual) .to.be.an('array') .that.deep.equals([{ component: 'x-home-view', path: '/' }]); }); it('should have a method for removing routes', () => { const router = new Resolver([{ component: 'x-home-view', path: '/' }]); expect(router.getRoutes()).to.be.an('array').that.has.lengthOf(1); router.removeRoutes(); expect(router.getRoutes()).to.be.an('array').that.has.lengthOf(0); }); }); describe('resolver.resolve({ pathname, ...context })', () => { it('should throw an error if no route found', async () => { const resolver = new Resolver([]); let error; try { await resolver.resolve('/'); } catch (e) { error = e; } expect(error).to.be.an('error'); expect(error) .to.have.property('message') .that.matches(/Page not found/u); expect(error).to.have.property('code').that.equals(404); expect(error).to.have.property('context').that.includes({ pathname: '/', resolver }); }); it("should execute the matching route's action method and return its result", async () => { const action = sinon.spy(() => 'b'); const resolver = new Resolver({ action, path: '/a' }); const context = await resolver.resolve('/a'); expect(action.calledOnce).to.be.true; expect(action.firstCall.firstArg).to.have.nested.property('route.path', '/a'); expect((context as RouteContext).result).to.be.equal('b'); }); it('should find the first route whose action method !== undefined or null', async () => { const action1 = sinon.spy(() => undefined); const action2 = sinon.spy(() => null); const action3 = sinon.spy(() => 'c'); const action4 = sinon.spy(() => 'd'); const resolver = new Resolver([ { action: action1, path: '/a' }, { action: action2, path: '/a' }, { action: action3, path: '/a' }, { action: action4, path: '/a' }, ]); const context = await resolver.resolve('/a'); expect((context as RouteContext).result).to.be.equal('c'); expect(action1.calledOnce).to.be.true; expect(action2.calledOnce).to.be.true; expect(action3.calledOnce).to.be.true; expect(action4.called).to.be.false; }); it('should be able to pass context variables to action methods', async () => { const action = sinon.spy(() => true); const resolver = new Resolver([{ action, path: '/a' }]); const context = await resolver.resolve({ pathname: '/a', test: 'b' }); expect(action.calledOnce).to.be.true; expect(action.firstCall.firstArg).to.have.nested.property('route.path', '/a'); expect(action.firstCall.firstArg).to.have.property('test', 'b'); expect((context as RouteContext).result).to.be.true; }); it("should not call action methods of routes that don't match the URL path", async () => { const action = sinon.spy(); const resolver = new Resolver([{ action, path: '/a' }]); let err; try { await resolver.resolve('/b'); } catch (e) { err = e; } expect(err).to.be.an('error'); expect(err) .to.have.property('message') .that.matches(/Page not found/u); expect(err).to.have.property('code').that.equals(404); expect(action.called).to.be.false; }); it('should support asynchronous route actions', async () => { const resolver = new Resolver([{ action: async () => await Promise.resolve('b'), path: '/a' }]); const context = await resolver.resolve('/a'); expect(context).to.have.property('result').that.equals('b'); }); it('URL parameters are captured and added to context.params', async () => { const action = sinon.spy(() => true); const resolver = new Resolver([{ action, path: '/:one/:two' }]); const context = await resolver.resolve({ pathname: '/a/b' }); expect(action.calledOnce).to.be.true; expect(action.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a', two: 'b' }); expect(context).to.have.property('result').that.is.true; }); it('context.chain contains the path to the last matched route if context.next() is called', async () => { const resolver = new Resolver([ { action: async (context) => await context.next(), name: 'first', path: '/a' }, { action: () => true, name: 'second', path: '/a' }, ]); await resolver.resolve({ pathname: '/a' }); expect(resolver.context.chain).to.be.an('array').lengthOf(1); expect(resolver.context.chain?.[0].route).to.be.an('object'); expect(resolver.context.chain?.[0].route.name).to.equal('second'); }); it('the path to the route that produced the result, and the matched path are in the `context` (1))', async () => { const resolver = new Resolver([{ action: () => true, path: '/a/b' }]); await resolver.resolve({ pathname: '/a/b' }); expect(resolver.context.chain).to.be.an('array').lengthOf(1); expect(resolver.context.chain?.[0].path).to.equal('/a/b'); expect(resolver.context.chain?.[0].route.path).to.equal('/a/b'); }); it('paths with parameters should have each route activated without parameters replaced', async () => { const resolver = new Resolver([ { action: () => 'x-user-profile', path: '/users/:user' }, { action: () => 'x-image-view', path: '/image-:size(\\d+)px' }, { action: () => 'x-knowledge-base', path: '/kb/:path+/:id' }, ]); await resolver.resolve('/users/1'); expect(resolver.context.chain).to.be.an('array').lengthOf(1); expect(resolver.context.chain?.[0].route.path).to.equal('/users/:user'); await resolver.resolve('/image-15px'); expect(resolver.context.chain).to.be.an('array').lengthOf(1); expect(resolver.context.chain?.[0].route.path).to.equal('/image-:size(\\d+)px'); await resolver.resolve('/kb/folder/nested/1'); expect(resolver.context.chain).to.be.an('array').lengthOf(1); expect(resolver.context.chain?.[0].route.path).to.equal('/kb/:path+/:id'); }); it('the path to the route that produced the result is in the `context` (2)', async () => { const resolver = new Resolver([ { children: [ { action: () => true, path: '/b', }, ], path: '/a', }, ]); await resolver.resolve({ pathname: '/a/b' }); expect(resolver.context.chain).to.be.an('array').lengthOf(2); expect(resolver.context.chain?.[0].route.path).to.equal('/a'); expect(resolver.context.chain?.[1].route.path).to.equal('/b'); }); it('the path to the route that produced the result is in the `context` (3)', async () => { const resolver = new Resolver([ { children: [ { children: [ { action: () => true, path: '/a', }, ], path: '', }, ], path: '/', }, { action: () => true, path: '/b' }, ]); await resolver.resolve({ pathname: '/b' }); const { context } = resolver; expect(context.chain).to.be.an('array').lengthOf(1); expect(context.chain?.[0].route.path).to.equal('/b'); }); it('should provide all URL parameters to each route', async () => { const action1 = sinon.spy(); const action2 = sinon.spy(() => true); const resolver = new Resolver([ { action: action1, children: [ { action: action2, path: '/:two', }, ], path: '/:one', }, ]); const context = await resolver.resolve({ pathname: '/a/b' }); expect(action1.calledOnce).to.be.true; expect(action1.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a' }); expect(action2.calledOnce).to.be.true; expect(action2.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a', two: 'b' }); expect(context).to.have.property('result').that.is.true; }); it('should override URL parameters with same name in child route', async () => { const action1 = sinon.spy(); const action2 = sinon.spy(() => true); const resolver = new Resolver([ { action: action1, children: [ { action: action1, path: '/:one', }, { action: action2, path: '/:two', }, ], path: '/:one', }, ]); const context = await resolver.resolve({ pathname: '/a/b' }); expect(action1.calledTwice).to.be.true; expect(action1.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a' }); expect(action1.args[1][0]).to.have.property('params').that.deep.equals({ one: 'b' }); expect(action2.calledOnce).to.be.true; expect(action2.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a', two: 'b' }); expect(context).to.have.property('result').that.is.true; }); it('should not collect parameters from previous routes', async () => { const action1 = sinon.spy(() => undefined); const action2 = sinon.spy(() => undefined); const action3 = sinon.spy(() => true); const resolver = new Resolver([ { action: action1, children: [ { action: action1, path: '/:two', }, ], path: '/:one', }, { action: action2, children: [ { action: action2, path: '/:four', }, { action: action3, path: '/:five', }, ], path: '/:three', }, ]); const context = await resolver.resolve({ pathname: '/a/b' }); expect(action1.calledTwice).to.be.true; expect(action1.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a' }); expect(action1.secondCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a', two: 'b' }); expect(action2.calledTwice).to.be.true; expect(action2.firstCall.firstArg).to.have.property('params').that.deep.equals({ three: 'a' }); expect(action2.secondCall.firstArg).to.have.property('params').that.deep.equals({ four: 'b', three: 'a' }); expect(action3.calledOnce).to.be.true; expect(action3.firstCall.firstArg).to.have.property('params').that.deep.equals({ five: 'b', three: 'a' }); expect(context).to.have.property('result').that.is.true; }); it('should support next() across multiple routes', async () => { const log: number[] = []; const resolver = new Resolver([ { async action({ next }) { log.push(1); const result = await next(); log.push(10); return result; }, children: [ { action() { log.push(2); }, children: [ { async action({ next }) { log.push(3); return await next().then(() => { log.push(6); }); }, children: [ { async action({ next }) { log.push(4); return await next().then(() => { log.push(5); }); }, path: '', }, ], path: '', }, ], path: '', }, { action() { log.push(7); }, children: [ { action() { log.push(8); }, path: '', }, { action() { log.push(9); }, path: '(.*)', }, ], path: '', }, ], path: '/test', }, { action() { log.push(11); }, path: '/:id', }, { action() { log.push(12); return 'done'; }, path: '/test', }, { action() { log.push(13); }, path: '/*', }, ]); const context = await resolver.resolve('/test'); expect(log).to.be.deep.equal([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); expect(context).to.have.property('result').that.equals('done'); }); it('should support next(true) across multiple routes', async () => { const log: number[] = []; const resolver = new Resolver({ path: '', async action({ next }) { log.push(1); return await next().then((result) => { log.push(9); return result; }); }, children: [ { async action({ next }) { log.push(2); return await next(true).then((result) => { log.push(8); return result; }); }, path: '/a/b/c', }, { action() { log.push(3); }, children: [ { async action({ next }) { log.push(4); return await next().then((result) => { log.push(6); return result; }); }, children: [ { action() { log.push(5); }, path: '/c', }, ], path: '/b', }, { action() { log.push(7); return 'done'; }, path: '/b/c', }, ], path: '/a', }, ], }); const context = await resolver.resolve('/a/b/c'); expect(log).to.be.deep.equal([1, 2, 3, 4, 5, 6, 7, 8, 9]); expect(context).to.have.property('result').that.equals('done'); }); it('should support parametrized routes 1', async () => { const action = sinon.spy(() => true); const resolver = new Resolver([{ action, path: '/path/:a/other/:b' }]); const context = await resolver.resolve('/path/1/other/2'); expect(action.calledOnce).to.be.true; expect(action.firstCall.firstArg).to.have.nested.property('params.a', '1'); expect(action.firstCall.firstArg).to.have.nested.property('params.b', '2'); expect(action.firstCall.firstArg).to.have.nested.property('params.a', '1'); expect(action.firstCall.firstArg).to.have.nested.property('params.b', '2'); expect(context).to.have.property('result').that.is.true; }); it('should support nested routes (1)', async () => { const action1 = sinon.spy(); const action2 = sinon.spy(() => true); const resolver = new Resolver([ { action: action1, children: [ { action: action2, path: '/a', }, ], path: '', }, ]); const context = await resolver.resolve('/a'); expect(action1.calledOnce).to.be.true; expect(action1.firstCall.firstArg).to.have.nested.property('route.path', ''); expect(action2.calledOnce).to.be.true; expect(action2.firstCall.firstArg).to.have.nested.property('route.path', '/a'); expect(context).to.have.property('result').that.is.true; }); it('should support nested routes (2)', async () => { const action1 = sinon.spy(); const action2 = sinon.spy(() => true); const resolver = new Resolver([ { action: action1, children: [ { action: action2, path: '/b', }, ], path: '/a', }, ]); const context = await resolver.resolve('/a/b'); expect(action1.calledOnce).to.be.true; expect(action1.firstCall.firstArg).to.have.nested.property('route.path', '/a'); expect(action2.calledOnce).to.be.true; expect(action2.firstCall.firstArg).to.have.nested.property('route.path', '/b'); expect(context).to.have.property('result').that.is.true; }); it('should support nested routes (3)', async () => { const action1 = sinon.spy(() => undefined); const action2 = sinon.spy(() => null); const action3 = sinon.spy(() => true); const resolver = new Resolver([ { action: action1, children: [ { action: action2, path: '/b', }, ], path: '/a', }, { action: action3, path: '/a/b', }, ]); const context = await resolver.resolve('/a/b'); expect(action1.calledOnce).to.be.true; expect(action1.firstCall.firstArg).to.have.nested.property('route.path', '/a'); expect(action2.calledOnce).to.be.true; expect(action2.firstCall.firstArg).to.have.nested.property('route.path', '/b'); expect(action3.calledOnce).to.be.true; expect(action3.firstCall.firstArg).to.have.nested.property('route.path', '/a/b'); expect(context).to.have.property('result').that.is.true; }); it('should support an empty array of children', async () => { const action = sinon.spy(); const resolver = new Resolver([ { action, children: [], path: '/a', }, ]); await resolver.resolve('/a/b').catch(() => {}); expect(action).to.have.been.calledOnce; }); it('should re-throw an error', async () => { const error = new Error('test error'); const resolver = new Resolver([ { action() { throw error; }, path: '/a', }, ]); let err; try { await resolver.resolve('/a'); } catch (e) { err = e; } expect(err).to.be.equal(error); }); it('should respect baseUrl', async () => { const action = sinon.spy(() => 17); const targetRoute = { action, path: '/c' }; const route: Route = { children: [ { children: [targetRoute], path: '/b', }, ], path: '/a', }; const resolver = new Resolver(route, { baseUrl: '/base/' }); const context = await resolver.resolve('/base/a/b/c'); expect(action.calledOnce).to.be.true; expect(action.firstCall.firstArg).to.have.property('pathname', '/base/a/b/c'); expect(action.firstCall.firstArg).to.have.nested.property('route.path', '/c'); expect(action.firstCall.firstArg).to.have.property('route').that.equals(targetRoute); expect(action.firstCall.firstArg).to.have.property('resolver', resolver); expect(context).to.have.property('result').that.equals(17); let err; try { await resolver.resolve('/a/b/c'); } catch (e) { err = e; } expect(action.calledOnce).to.be.true; expect(err).to.be.an('error'); expect(err) .to.have.property('message') .that.matches(/Page not found/u); expect(err).to.have.property('code').that.equals(404); expect(err).to.have.nested.property('context.pathname').that.equals('/a/b/c'); expect(err).to.not.have.nested.property('context.path'); expect(err).to.have.nested.property('context.resolver').that.equals(resolver); }); it('should match routes with trailing slashes', async () => { const resolver = new Resolver([ { action: () => 'a', path: '/' }, { action: () => 'b', path: '/page/' }, { children: [ { action: () => 'c', path: '/' }, { action: () => 'd', path: '/page/' }, ], path: '/child', }, ]); await expect(resolver.resolve('/')).to.eventually.have.property('result').that.equals('a'); await expect(resolver.resolve('/page/')).to.eventually.have.property('result').that.equals('b'); await expect(resolver.resolve('/child/')).to.eventually.have.property('result').that.equals('c'); await expect(resolver.resolve('/child/page/')).to.eventually.have.property('result').that.equals('d'); }); it('should skip nested routes when middleware route returns null', async () => { const middleware = sinon.spy(() => null); const action = sinon.spy(() => 'skipped'); const resolver = new Resolver([ { action: middleware, children: [{ path: '', action }], path: '/match', }, { action: () => 404, path: '/match', }, ]); const context = await resolver.resolve('/match'); expect(context).to.have.property('result').that.equals(404); expect(action.called).to.be.false; expect(middleware.calledOnce).to.be.true; }); it('should match nested routes when middleware route returns undefined', async () => { const middleware = sinon.spy(() => undefined); const action = sinon.spy(() => null); const resolver = new Resolver([ { action: middleware, children: [{ path: '', action }], path: '/match', }, { action: () => 404, path: '/match', }, ]); const context = await resolver.resolve('/match'); expect(context).to.have.property('result').that.equals(404); expect(action.calledOnce).to.be.true; expect(middleware.calledOnce).to.be.true; }); it('should match routes with object result', async () => { const resolver = new Resolver([ { action() { return { result: 'ok' }; }, path: '/match', }, ]); const context = await resolver.resolve('/match'); expect(context).to.have.property('result').that.deep.equals({ result: 'ok' }); }); }); // describe('Resolver.__createUrl(path, base) hook', () => { // it('should exist', () => { // expect(Resolver.__createUrl).to.be.instanceof(Function); // }); // // it('should return URL-like object', () => { // const absolutePathUrl = Resolver.__createUrl('/absolute/', 'http://example.com/base/url'); // expect(absolutePathUrl).to.have.property('href', 'http://example.com/absolute/'); // expect(absolutePathUrl).to.have.property('origin', 'http://example.com'); // expect(absolutePathUrl).to.have.property('pathname', '/absolute/'); // // const relativePathUrl = Resolver.__createUrl('relative', 'http://example.com/base/url'); // expect(relativePathUrl).to.have.property('href', 'http://example.com/base/relative'); // expect(relativePathUrl).to.have.property('origin', 'http://example.com'); // expect(relativePathUrl).to.have.property('pathname', '/base/relative'); // }); // }); describe('resolver.__effectiveBaseUrl getter', () => { it('should return empty string by default', () => { // @ts-expect-error: testing protected property expect(new Resolver([]).__effectiveBaseUrl).to.equal(''); }); it('should return full base when baseUrl is set', () => { // @ts-expect-error: testing protected property expect(new Resolver([], { baseUrl: '/foo/' }).__effectiveBaseUrl).to.equal(`${location.origin}/foo/`); }); it('should ignore everything after last slash', () => { // @ts-expect-error: testing protected property expect(new Resolver([], { baseUrl: '/foo' }).__effectiveBaseUrl).to.equal(`${location.origin}/`); // @ts-expect-error: testing protected property expect(new Resolver([], { baseUrl: '/foo/bar' }).__effectiveBaseUrl).to.equal(`${location.origin}/foo/`); }); // it('should invoke Resolver.__createUrl(path, base) hook', () => { // sinon.spy(Resolver, '__createUrl'); // try { // new Resolver([], { baseUrl: '/foo/bar' }).__effectiveBaseUrl; // expect(Resolver.__createUrl).to.be.calledWith('/foo/bar', document.baseURI || document.URL); // } finally { // Resolver.__createUrl.restore(); // } // }); }); describe('resolver.__normalizePathname(pathname) method', () => { it('should return unmodified pathname by default', () => { const resolver = new Resolver([]); // @ts-expect-error: testing private method expect(resolver.__normalizePathname('foo')).to.equal('foo'); // @ts-expect-error: testing private method expect(resolver.__normalizePathname('/bar')).to.equal('/bar'); }); it('should undefined when pathname does not match baseUrl', () => { const resolver = new Resolver([], { baseUrl: '/foo/' }); // @ts-expect-error: testing private method expect(resolver.__normalizePathname('/')).to.equal(undefined); // @ts-expect-error: testing private method expect(resolver.__normalizePathname('/bar')).to.equal(undefined); }); it('should local path when pathname matches baseUrl', () => { const resolver = new Resolver([], { baseUrl: '/foo/' }); // @ts-expect-error: testing private method expect(resolver.__normalizePathname('/foo/')).to.equal(''); // @ts-expect-error: testing private method expect(resolver.__normalizePathname('/foo/bar')).to.equal('bar'); // @ts-expect-error: testing private method expect(resolver.__normalizePathname('baz')).to.equal('baz'); }); it('should use __effectiveBaseUrl', () => { const resolver = new Resolver([], { baseUrl: '/foo/' }); const stub = sinon.stub().returns(`${location.origin}/bar/`); Object.defineProperty(resolver, '__effectiveBaseUrl', { get: stub }); // @ts-expect-error: testing private method expect(resolver.__normalizePathname('/bar/')).to.equal(''); expect(stub).to.be.called; }); }); }); ================================================ FILE: test/router/dynamic-redirect.spec.ts ================================================ import { expect, use } from '@esm-bundle/chai'; import chaiAsPromised from 'chai-as-promised'; import chaiDom from 'chai-dom'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { Router } from '../../src/router.js'; import '../setup.js'; import { cleanup, verifyActiveRoutes } from './test-utils.js'; use(chaiDom); use(sinonChai); use(chaiAsPromised); // eslint-disable-next-line prefer-arrow-callback describe('Vaadin.Router', () => { let outlet: HTMLElement; let router: Router; before(() => { outlet = document.createElement('div'); document.body.append(outlet); }); after(() => { outlet.remove(); }); beforeEach(() => { // create a new router instance router = new Router(outlet); }); afterEach(() => { cleanup(outlet); router.unsubscribe(); }); describe('resolver chain and router features', () => { it('redirect overwrites activated routes', async () => { await router.setRoutes( [ { path: '/a', children: [{ path: '/b', children: [{ path: '/c', component: 'x-home-view' }] }] }, { path: '/', redirect: '/a/b/c' }, ], true, ); await router.render('/'); verifyActiveRoutes(router, ['/a', '/b', '/c']); }); it('action that returns custom component activates route', async () => { await router.setRoutes([{ path: '/', action: (_context, commands) => commands.component('x-home-view') }], true); await router.render('/'); verifyActiveRoutes(router, ['/']); }); it('action that returns redirect activates redirect route', async () => { await router.setRoutes( [ { path: '/', action: (_context, commands) => commands.redirect('/a') }, { path: '/a', component: 'x-users-view' }, ], true, ); await router.render('/'); verifyActiveRoutes(router, ['/a']); expect(outlet.lastChild) .to.have.property('tagName') .that.matches(/x-users-view/iu); }); it('should be able to have multiple action redirects', async () => { await router.setRoutes( [ { path: '/', action: (_context, commands) => commands.redirect('/u') }, { path: '/u', action: (_context, commands) => commands.redirect('/users') }, { path: '/users', component: 'x-users-list' }, ], true, ); await router.render('/'); expect(outlet.lastChild) .to.have.property('tagName') .that.matches(/x-users-list/iu); verifyActiveRoutes(router, ['/users']); }); it('should fail on recursive action redirects', async () => { await router.setRoutes( [ { path: '/a', action: (_context, commands) => commands.redirect('/b') }, { path: '/b', action: (_context, commands) => commands.redirect('/c') }, { path: '/c', action: (_context, commands) => commands.redirect('/a') }, ], true, ); const onError = sinon.spy(); // eslint-disable-next-line @typescript-eslint/use-unknown-in-catch-callback-variable await router.render('/a').catch(onError); expect(outlet.children).to.have.lengthOf(0); expect(onError).to.have.been.calledOnce; }); }); }); ================================================ FILE: test/router/lifecycle-events.spec.ts ================================================ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; import { Router, type RouterLocation } from '../../src/index.js'; import Resolver from '../../src/resolver/resolver.js'; import '../setup.js'; import type { MaybePromise } from '../../src/resolver/types.t.js'; import type { Commands, Route, RouteContext, VaadinRouterErrorEvent, VaadinRouterLocationChangedEvent, WebComponentInterface, } from '../../src/types.t.js'; import { checkOutletContents, cleanup, onAfterEnterAction, onAfterLeaveAction, onBeforeEnterAction, onBeforeLeaveAction, verifyActiveRoutes, } from './test-utils.js'; declare global { interface HTMLElementTagNameMap { 'x-persistent-view': HTMLElement; 'x-spy': XSpy; } } let callbacksLog: string[] = []; class XSpy extends HTMLElement implements WebComponentInterface { location?: RouterLocation; name?: string; connectedCallback() { callbacksLog.push(`${this.name ?? 'x-spy'}.connectedCallback`); } disconnectedCallback() { callbacksLog.push(`${this.name ?? 'x-spy'}.disconnectedCallback`); } onBeforeEnter(): MaybePromise { callbacksLog.push(`${this.name ?? 'x-spy'}.onBeforeEnter`); return undefined; } onAfterEnter() { callbacksLog.push(`${this.name ?? 'x-spy'}.onAfterEnter`); } onBeforeLeave(): MaybePromise { callbacksLog.push(`${this.name ?? 'x-spy'}.onBeforeLeave`); return undefined; } onAfterLeave() { callbacksLog.push(`${this.name ?? 'x-spy'}.onAfterLeave`); } } customElements.define('x-spy', XSpy); function extractLifeCycleCallbackCallArgs( callback: sinon.SinonSpy, ): readonly [location: RouterLocation, commands: Commands, router: Router] { return callback.firstCall.args as [RouterLocation, Commands, Router]; } const elementWithAllLifecycleCallbacks = (elementName: string) => (_context: RouteContext, commands: Commands) => { callbacksLog.push(`${elementName}.action`); const component = commands.component('x-spy') as WebComponentInterface; component.name = elementName; return component; }; const elementWithUserParameter = () => (context: RouteContext, commands: Commands) => { const elementName = `x-user-${String(context.params.user ?? '')}`; callbacksLog.push(`${elementName}.action`); const component = commands.component('x-spy') as WebComponentInterface; if (!component.name) { component.name = elementName; } return component; }; const sleep = async (ms: number) => await new Promise((resolve) => { setTimeout(resolve, ms); }); describe('Vaadin Router lifecycle events', () => { const verifyCallbacks = (expectedCallbacks: readonly string[]) => { expect(callbacksLog).to.be.an('array'); expect(expectedCallbacks).to.be.an('array'); try { expect(callbacksLog).to.deep.equal(expectedCallbacks); } catch (e) { const comparisonTable = [['expected', 'actual']]; for (let i = 0; i < Math.max(expectedCallbacks.length, callbacksLog.length); i++) { comparisonTable.push([expectedCallbacks[i], callbacksLog[i]]); } // eslint-disable-next-line no-console console.table(comparisonTable); throw e; } }; let outlet: HTMLElement; let router: Router; before(() => { outlet = document.createElement('div'); document.body.append(outlet); history.pushState(null, '', '/'); }); after(() => { outlet.remove(); history.back(); }); beforeEach(() => { // create a new router instance router = new Router(outlet); history.replaceState(null, '', '/'); }); afterEach(() => { router.unsubscribe(); cleanup(outlet); callbacksLog = []; }); describe('onBeforeEnter', () => { it('should be called with 3 arguments: [location, commands, router]', async () => { const onBeforeEnter = sinon.spy(); await router.setRoutes([{ path: '/', action: onBeforeEnterAction('x-home-view', onBeforeEnter) }], true); await router.render('/'); expect(onBeforeEnter).to.have.been.calledOnce; expect(onBeforeEnter.args[0].length).to.equal(3); const [location, commands, routerArg] = extractLifeCycleCallbackCallArgs(onBeforeEnter); expect(location.pathname).to.equal('/'); expect(location.route?.path).to.equal('/'); expect(commands).to.be.an('object').that.is.not.null; expect(routerArg).to.equal(router); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should be called on the route web component instance (as `this`)', async () => { const onBeforeEnter = sinon.spy(); await router.setRoutes([{ path: '/', action: onBeforeEnterAction('x-home-view', onBeforeEnter) }], true); await router.render('/'); expect(onBeforeEnter).to.have.been.calledOn(outlet.children[0]); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should be able to return a `prevent` command to prevent navigation', async () => { await router.setRoutes( [ { path: '/', action: onBeforeEnterAction('whatever', (_location, commands) => commands.prevent()) }, { path: '/users', component: 'x-users-list' }, ], true, ); await router.render('/users'); await router.render('/'); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); verifyActiveRoutes(router, ['/users']); }); it('should keep the location when route is prevented on before enter', async () => { // this test is not failed on chrome before #365 // probably because of https://bugs.chromium.org/p/chromium/issues/detail?id=983094 let preventNavigation = false; await router.setRoutes( [ { path: '/', action: onBeforeEnterAction('x-home-view', (_location, commands) => preventNavigation ? commands.prevent() : undefined, ), }, { path: '/users', component: 'x-users-list' }, ], true, ); await router.ready; expect(window.location.pathname).to.be.equal('/'); await router.render({ pathname: '/users', search: '', hash: '' }, true); expect(window.location.pathname).to.be.equal('/users'); preventNavigation = true; window.history.back(); await router.ready; expect(window.location.pathname).to.be.equal('/users'); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); verifyActiveRoutes(router, ['/users']); }); it('should be able to return a `redirect` command to redirect navigation', async () => { await router.setRoutes( [ { path: '/', action: onBeforeEnterAction('whatever', (_location, commands) => commands.redirect('/users')) }, { path: '/users', component: 'x-users-list' }, ], true, ); await router.render('/'); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); verifyActiveRoutes(router, ['/users']); }); it('should be able to have multiple redirects', async () => { await router.setRoutes( [ { path: '/', action: onBeforeEnterAction('x-redirect-component', (_location, commands) => commands.redirect('/u')), }, { path: '/u', action: onBeforeEnterAction('x-redirect-component', (_location, commands) => commands.redirect('/users')), }, { path: '/users', component: 'x-users-list' }, ], true, ); await router.render('/'); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); verifyActiveRoutes(router, ['/users']); }); it('should fail on recursive redirects', async () => { await router.setRoutes( [ { path: '/', action: onBeforeEnterAction('x-redirect-component', (_location, commands) => commands.redirect('/u')), }, { path: '/u', action: onBeforeEnterAction('x-redirect-component', (_location, commands) => commands.redirect('/users')), }, { path: '/users', action: onBeforeEnterAction('x-redirect-component', (_location, commands) => commands.redirect('/')), }, ], true, ); const onError = sinon.spy((_: unknown) => {}); await router.render('/').catch(onError); expect(outlet.children).to.have.lengthOf(0); expect(onError).to.have.been.calledOnce; }); it('should ignore any other return value than `prevent` or `redirect`', async () => { const values = [ true, false, 0, 42, [], // eslint-disable-next-line camelcase { not_a_redirect: true }, null, undefined, () => true, 'random-tag-name', document.createElement('div'), ]; for (const value of values) { const onBeforeEnter = sinon.stub().returns(value); // eslint-disable-next-line no-await-in-loop await router.setRoutes( [ { path: '/', action: onBeforeEnterAction('x-home-view', onBeforeEnter) }, { path: '/users', component: 'x-users-list' }, ], true, ); // eslint-disable-next-line no-await-in-loop await router.render('/'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); verifyActiveRoutes(router, ['/']); } }); it('should support returning a promise (and continue the resolve pass after the promise resolves)', async () => { await router.setRoutes( [ { path: '/a', action: onBeforeEnterAction( 'x-spy', async () => { callbacksLog.push('a.onBeforeEnter'); await sleep(100); callbacksLog.push('a.onBeforeEnter.promise'); return undefined; }, 'a', ), }, { path: '/b', component: 'x-spy' }, ], true, ); await router.render('/a'); verifyCallbacks(['a.onBeforeEnter', 'a.onBeforeEnter.promise', 'a.connectedCallback', 'a.onAfterEnter']); }); it('should not re-render the same component if `onBeforeLeave` prevented navigation', async () => { let counter = 0; customElements.define( 'x-persistent-view', class PersistentView extends HTMLElement implements WebComponentInterface { connectedCallback(): void { counter += 1; } onBeforeLeave(_location: RouterLocation, commands: Commands) { return commands.prevent(); } }, ); await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/users', component: 'x-persistent-view' }, ], true, ); await router.render('/users'); await router.render('/'); expect(outlet.children[0].tagName).to.match(/x-persistent-view/iu); expect(counter).to.equal(1); }); }); describe('onBeforeLeave', () => { it('should be called with 3 arguments: [location, commands, router]', async () => { const onBeforeLeave = sinon.spy(); await router.setRoutes( [ { path: '/', action: onBeforeLeaveAction('x-home-view', onBeforeLeave) }, { path: '/users', component: 'x-users-list' }, ], true, ); await router.render('/'); expect(onBeforeLeave).to.not.have.been.called; await router.render('/users'); expect(onBeforeLeave).to.have.been.calledOnce; expect(onBeforeLeave.args[0].length).to.equal(3); const [location, commands, routerArg] = extractLifeCycleCallbackCallArgs(onBeforeLeave); expect(location.pathname).to.equal('/users'); expect(location.route?.path).to.equal('/users'); expect(commands).to.be.an('object').that.is.not.null; expect(routerArg).to.equal(router); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); }); it('should be called on the route web component instance (as `this`)', async () => { const onBeforeLeave = sinon.spy(); await router.setRoutes([ { path: '/', action: onBeforeLeaveAction('x-home-view', onBeforeLeave) }, { path: '/users', component: 'x-users-list' }, ]); await router.render('/'); const [homeViewElement] = outlet.children; await router.render('/users'); expect(onBeforeLeave).to.have.been.calledOn(homeViewElement); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); }); it('should be able to return a `prevent` command to prevent navigation', async () => { await router.setRoutes([ { path: '/', action: onBeforeLeaveAction('x-home-view', (_location, commands) => commands.prevent()) }, { path: '/users', component: 'x-users-list' }, ]); await router.render('/'); await router.render('/users'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); verifyActiveRoutes(router, ['/']); }); it('should keep the location when route is prevented on before leave', async () => { // this test is not failed on chrome before #365 // probably because of https://bugs.chromium.org/p/chromium/issues/detail?id=983094 let preventNavigation = false; await router.setRoutes([ { path: '/', action: onBeforeLeaveAction('x-home-view', (_location, commands) => preventNavigation ? commands.prevent() : undefined, ), }, { path: '/users', component: 'x-users-list' }, ]); await router.ready; expect(window.location.pathname).to.be.equal('/'); await router.render({ pathname: '/users', search: '', hash: '' }, true); expect(window.location.pathname).to.be.equal('/users'); await router.render({ pathname: '/', search: '', hash: '' }, true); expect(window.location.pathname).to.be.equal('/'); preventNavigation = true; window.history.back(); await router.ready; expect(window.location.pathname).to.be.equal('/'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); verifyActiveRoutes(router, ['/']); }); it('should ignore any other return value than `prevent`', async () => { const values = [ true, false, 0, 42, [], // eslint-disable-next-line camelcase { not_a_redirect: true }, { redirect: { pathname: '/' } }, null, undefined, () => true, 'random-tag-name', document.createElement('div'), ]; for (const value of values) { const onBeforeLeave = sinon.stub().returns(value); // eslint-disable-next-line no-await-in-loop await router.setRoutes([ { path: '/', action: onBeforeLeaveAction('x-home-view', onBeforeLeave) }, { path: '/users', component: 'x-users-list' }, ]); // eslint-disable-next-line no-await-in-loop await router.render('/'); // eslint-disable-next-line no-await-in-loop await router.render('/users'); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); verifyActiveRoutes(router, ['/users']); } }); it('should support returning a promise (and continue the resolve pass after the promise resolves)', async () => { await router.setRoutes( [ { path: '/a', action: onBeforeLeaveAction( 'x-spy', async () => { callbacksLog.push('a.onBeforeLeave'); await sleep(100); callbacksLog.push('a.onBeforeLeave.promise'); return undefined; }, 'a', ), }, { path: '/b', action: elementWithAllLifecycleCallbacks('b') }, ], true, ); await router.render('/').catch(() => {}); await router.render('/a'); callbacksLog = []; await router.render('/b'); verifyCallbacks([ 'b.action', 'a.onBeforeLeave', 'a.onBeforeLeave.promise', 'b.onBeforeEnter', 'b.connectedCallback', 'b.onAfterEnter', 'a.onAfterLeave', 'a.disconnectedCallback', ]); }); it('should not re-render the same component if `onBeforeEnter` prevented navigation', async () => { let counter = 0; customElements.define( 'x-root-view', class extends HTMLElement { // eslint-disable-next-line @typescript-eslint/class-methods-use-this connectedCallback(): void { counter += 1; } }, ); customElements.define( 'x-disallowed-view', class extends HTMLElement implements WebComponentInterface { onBeforeEnter(_location: RouterLocation, commands: Commands) { return commands.prevent(); } }, ); await router.setRoutes([ { path: '/', component: 'x-root-view' }, { path: '/users', component: 'x-disallowed-view' }, ]); await router.render('/'); await router.render('/users'); expect(outlet.children[0].tagName).to.match(/x-root-view/iu); expect(counter).to.equal(1); }); }); describe('onAfterLeave', () => { it('should be called with 3 arguments: [location, commands, router]', async () => { const onAfterLeave = sinon.spy(); await router.setRoutes( [ { path: '/', action: onAfterLeaveAction('x-home-view', onAfterLeave) }, { path: '/users', component: 'x-users-list' }, ], true, ); await router.render('/'); expect(onAfterLeave).not.to.have.been.called; await router.render('/users'); expect(onAfterLeave).to.have.been.calledOnce; expect(onAfterLeave.args[0].length).to.equal(3); const [location, commands, routerArg] = extractLifeCycleCallbackCallArgs(onAfterLeave); expect(location.pathname).to.equal('/users'); expect(location.route?.path).to.equal('/users'); expect(commands).to.be.an('object').that.is.not.null; // eslint-disable-next-line @typescript-eslint/unbound-method expect(commands.prevent).to.be.undefined; // eslint-disable-next-line @typescript-eslint/unbound-method expect(commands.redirect).to.be.undefined; // eslint-disable-next-line @typescript-eslint/unbound-method expect(commands.component).to.be.undefined; expect(routerArg).to.equal(router); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); }); it('should be called on the route web component instance (as `this`)', async () => { const onAfterLeave = sinon.spy(); await router.setRoutes([ { path: '/', action: onAfterLeaveAction('x-home-view', onAfterLeave) }, { path: '/users', component: 'x-users-list' }, ]); await router.render('/'); const [homeViewElement] = outlet.children; await router.render('/users'); expect(onAfterLeave).to.have.been.calledOn(homeViewElement); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); }); it('should ignore all return values', async () => { const values = [ true, false, 0, 42, [], // eslint-disable-next-line camelcase { not_a_redirect: true }, { redirect: { pathname: '/' } }, { cancel: true }, null, undefined, () => true, 'random-tag-name', document.createElement('div'), ]; for (const value of values) { const onAfterLeave = sinon.stub().returns(value); // eslint-disable-next-line no-await-in-loop await router.setRoutes([ { path: '/', action: onAfterLeaveAction('x-home-view', onAfterLeave) }, { path: '/users', component: 'x-users-list' }, ]); // eslint-disable-next-line no-await-in-loop await router.render('/'); // eslint-disable-next-line no-await-in-loop await router.render('/users'); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); verifyActiveRoutes(router, ['/users']); } }); }); describe('onAfterEnter', () => { it('should be called with 3 argument: [location, commands, router]', async () => { const onAfterEnter = sinon.spy(); await router.setRoutes([{ path: '/', action: onAfterEnterAction('x-home-view', onAfterEnter) }], true); await router.render('/'); expect(onAfterEnter).to.have.been.calledOnce; expect(onAfterEnter.args[0].length).to.equal(3); const [location, commands, routerArg] = extractLifeCycleCallbackCallArgs(onAfterEnter); expect(location.pathname).to.equal('/'); expect(location.route?.path).to.equal('/'); expect(commands).to.be.an('object').that.is.not.null; // eslint-disable-next-line @typescript-eslint/unbound-method expect(commands.prevent).to.be.undefined; // eslint-disable-next-line @typescript-eslint/unbound-method expect(commands.redirect).to.be.undefined; // eslint-disable-next-line @typescript-eslint/unbound-method expect(commands.component).to.be.undefined; expect(routerArg).to.equal(router); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should be called on the route web component instance (as `this`)', async () => { const onAfterEnter = sinon.spy(); await router.setRoutes([{ path: '/', action: onAfterEnterAction('x-home-view', onAfterEnter) }], true); await router.render('/'); expect(onAfterEnter).to.have.been.calledOn(outlet.children[0]); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should ignore all return values', async () => { const values = [ true, false, 0, 42, [], // eslint-disable-next-line camelcase { not_a_redirect: true }, { redirect: { pathname: '/' } }, { cancel: true }, null, undefined, () => true, 'random-tag-name', document.createElement('div'), ]; for (const value of values) { const onAfterEnter = sinon.stub().returns(value); // eslint-disable-next-line no-await-in-loop await router.setRoutes( [ { path: '/', action: onAfterEnterAction('x-home-view', onAfterEnter) }, { path: '/users', component: 'x-users-list' }, ], true, ); // eslint-disable-next-line no-await-in-loop await router.render('/'); // eslint-disable-next-line no-await-in-loop await router.render('/users'); expect(outlet.children[0].tagName).to.match(/x-users-list/iu); verifyActiveRoutes(router, ['/users']); } }); }); describe('the order of lifecycle events (without early returns)', () => { function action() { callbacksLog.push('a.action'); return undefined; } it('(initial) -> /a', async () => { await router.setRoutes([{ path: '/a', action: elementWithAllLifecycleCallbacks('a') }], true); await router.render('/').catch(() => {}); callbacksLog = []; await router.render('/a'); verifyCallbacks(['a.action', 'a.onBeforeEnter', 'a.connectedCallback', 'a.onAfterEnter']); }); it('/a -> /b', async () => { await router.setRoutes( [ { path: '/a', action: elementWithAllLifecycleCallbacks('a') }, { path: '/b', action: elementWithAllLifecycleCallbacks('b') }, ], true, ); await router.render('/a'); callbacksLog = []; await router.render('/b'); verifyCallbacks([ 'b.action', 'a.onBeforeLeave', 'b.onBeforeEnter', 'b.connectedCallback', 'b.onAfterEnter', 'a.onAfterLeave', 'a.disconnectedCallback', ]); }); it('(initial) -> /a/b', async () => { await router.setRoutes( [ { path: '/a', action, children: [{ path: '/b', action: elementWithAllLifecycleCallbacks('b') }], }, ], true, ); await router.render('/').catch(() => {}); callbacksLog = []; await router.render('/a/b'); verifyCallbacks(['a.action', 'b.action', 'b.onBeforeEnter', 'b.connectedCallback', 'b.onAfterEnter']); }); it('/a/b -> /a/c', async () => { await router.setRoutes( [ { path: '/a', action, children: [ { path: '/b', action: elementWithAllLifecycleCallbacks('b') }, { path: '/c', action: elementWithAllLifecycleCallbacks('c') }, ], }, ], true, ); await router.render('/a/b'); callbacksLog = []; await router.render('/a/c'); verifyCallbacks([ 'a.action', 'c.action', 'b.onBeforeLeave', 'c.onBeforeEnter', 'c.connectedCallback', 'c.onAfterEnter', 'b.onAfterLeave', 'b.disconnectedCallback', ]); }); it('(initial) -> /a/non-existent-path', async () => { await router.render('/').catch(() => {}); callbacksLog = []; // call 'setRoutes' without triggering a navigation event Resolver.prototype.setRoutes.call(router, [ { path: '/a', action, children: [{ path: '/b', action: elementWithAllLifecycleCallbacks('b') }], }, { path: '(.*)', action: elementWithAllLifecycleCallbacks('asterisk') }, ]); await router.render('/a/non-existent-path'); verifyCallbacks([ 'a.action', 'asterisk.action', 'asterisk.onBeforeEnter', 'asterisk.connectedCallback', 'asterisk.onAfterEnter', ]); }); it('/a/b -> /a/non-existent-path', async () => { await router.setRoutes([ { path: '/a', action, children: [{ path: '/b', action: elementWithAllLifecycleCallbacks('b') }], }, { path: '(.*)', action: elementWithAllLifecycleCallbacks('asterisk') }, ]); await router.render('/a/b'); callbacksLog = []; await router.render('/a/non-existent-path'); verifyCallbacks([ 'a.action', 'asterisk.action', 'b.onBeforeLeave', 'asterisk.onBeforeEnter', 'asterisk.connectedCallback', 'asterisk.onAfterEnter', 'b.onAfterLeave', 'b.disconnectedCallback', ]); }); it('/a/c -> /a/d (/a gets visited, but does not get matched)', async () => { await router.setRoutes( [ { path: '/a', action, children: [ { path: '/b', action() { callbacksLog.push('b.action'); return undefined; }, component: 'x-spy', }, ], }, { path: '/a/c', action: elementWithAllLifecycleCallbacks('ac') }, { path: '/a/d', action: elementWithAllLifecycleCallbacks('ad') }, ], true, ); await router.render('/a/c'); callbacksLog = []; await router.render('/a/d'); verifyCallbacks([ 'a.action', 'ad.action', 'ac.onBeforeLeave', 'ad.onBeforeEnter', 'ad.connectedCallback', 'ad.onAfterEnter', 'ac.onAfterLeave', 'ac.disconnectedCallback', ]); }); it('/users/jane -> /users/john (when parameters are changed, all callbacks are called again)', async () => { await router.setRoutes([{ path: '/users/:user', action: elementWithUserParameter() }], true); await router.render('/users/jane'); callbacksLog = []; await router.render('/users/john'); verifyCallbacks([ 'x-user-john.action', 'x-user-jane.onBeforeLeave', 'x-user-john.onBeforeEnter', 'x-user-john.connectedCallback', 'x-user-john.onAfterEnter', 'x-user-jane.onAfterLeave', 'x-user-jane.disconnectedCallback', ]); }); }); describe('lifecycle events for nested routes', () => { const checkOutlet = (values: readonly string[]) => checkOutletContents(outlet.children[0] as WebComponentInterface, 'name', values); beforeEach(async () => { await router.setRoutes( [ { path: '/', component: 'div' }, { path: '/a', action: elementWithAllLifecycleCallbacks('x-a'), children: [ { path: '/b', action: elementWithAllLifecycleCallbacks('x-b'), children: [{ path: '/e', action: elementWithAllLifecycleCallbacks('x-e') }], }, { path: '/d', action: elementWithAllLifecycleCallbacks('x-d') }, ], }, { path: '/c', action: elementWithAllLifecycleCallbacks('x-c') }, ], true, ); callbacksLog = []; }); it('/a/b -> /a/b', async () => { await router.render('/a/b'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-a.onBeforeEnter', 'x-b.onBeforeEnter', 'x-a.connectedCallback', 'x-b.connectedCallback', 'x-a.onAfterEnter', 'x-b.onAfterEnter', ]); checkOutlet(['x-a', 'x-b']); callbacksLog = []; await router.render('/a/b'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-b.onBeforeLeave', 'x-a.onBeforeLeave', 'x-a.onBeforeEnter', 'x-b.onBeforeEnter', ]); checkOutlet(['x-a', 'x-b']); }); it('/a/b -> /c', async () => { await router.render('/a/b'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-a.onBeforeEnter', 'x-b.onBeforeEnter', 'x-a.connectedCallback', 'x-b.connectedCallback', 'x-a.onAfterEnter', 'x-b.onAfterEnter', ]); checkOutlet(['x-a', 'x-b']); callbacksLog = []; await router.render('/c'); verifyCallbacks([ 'x-c.action', 'x-b.onBeforeLeave', 'x-a.onBeforeLeave', 'x-c.onBeforeEnter', 'x-c.connectedCallback', 'x-c.onAfterEnter', 'x-b.onAfterLeave', 'x-a.onAfterLeave', 'x-b.disconnectedCallback', 'x-a.disconnectedCallback', ]); checkOutlet(['x-c']); }); it('/a/b -> /a/d', async () => { await router.render('/a/b'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-a.onBeforeEnter', 'x-b.onBeforeEnter', 'x-a.connectedCallback', 'x-b.connectedCallback', 'x-a.onAfterEnter', 'x-b.onAfterEnter', ]); checkOutlet(['x-a', 'x-b']); callbacksLog = []; await router.render('/a/d'); verifyCallbacks([ 'x-a.action', 'x-d.action', 'x-b.onBeforeLeave', 'x-d.onBeforeEnter', 'x-d.connectedCallback', 'x-d.onAfterEnter', 'x-b.onAfterLeave', 'x-b.disconnectedCallback', ]); checkOutlet(['x-a', 'x-d']); }); it('/a/b -> /a/b/e', async () => { await router.render('/a/b'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-a.onBeforeEnter', 'x-b.onBeforeEnter', 'x-a.connectedCallback', 'x-b.connectedCallback', 'x-a.onAfterEnter', 'x-b.onAfterEnter', ]); checkOutlet(['x-a', 'x-b']); callbacksLog = []; await router.render('/a/b/e'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-e.action', 'x-e.onBeforeEnter', 'x-e.connectedCallback', 'x-e.onAfterEnter', ]); checkOutlet(['x-a', 'x-b', 'x-e']); }); it('/a/b -> /a/b/e with extra root path', async () => { await router.setRoutes( [ { path: '/a', action: elementWithAllLifecycleCallbacks('x-a'), children: [ { path: '/b', action: elementWithAllLifecycleCallbacks('x-b'), children: [ { path: '/', action: elementWithAllLifecycleCallbacks('x-b-root') }, { path: '/e', action: elementWithAllLifecycleCallbacks('x-e') }, ], }, { path: '/d', action: elementWithAllLifecycleCallbacks('x-d') }, ], }, { path: '/c', action: elementWithAllLifecycleCallbacks('x-c') }, ], true, ); callbacksLog = []; await router.render('/a/b'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-b-root.action', 'x-a.onBeforeEnter', 'x-b.onBeforeEnter', 'x-b-root.onBeforeEnter', 'x-a.connectedCallback', 'x-b.connectedCallback', 'x-b-root.connectedCallback', 'x-a.onAfterEnter', 'x-b.onAfterEnter', 'x-b-root.onAfterEnter', ]); checkOutlet(['x-a', 'x-b', 'x-b-root']); callbacksLog = []; await router.render('/a/b/e'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-e.action', 'x-b-root.onBeforeLeave', 'x-e.onBeforeEnter', 'x-e.connectedCallback', 'x-e.onAfterEnter', 'x-b-root.onAfterLeave', 'x-b-root.disconnectedCallback', ]); checkOutlet(['x-a', 'x-b', 'x-e']); }); it('/a/b/e -> /a/b', async () => { await router.render('/a/b/e'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-e.action', 'x-a.onBeforeEnter', 'x-b.onBeforeEnter', 'x-e.onBeforeEnter', 'x-a.connectedCallback', 'x-b.connectedCallback', 'x-e.connectedCallback', 'x-a.onAfterEnter', 'x-b.onAfterEnter', 'x-e.onAfterEnter', ]); checkOutlet(['x-a', 'x-b', 'x-e']); callbacksLog = []; await router.render('/a/b'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-e.onBeforeLeave', 'x-e.onAfterLeave', 'x-e.disconnectedCallback', ]); checkOutlet(['x-a', 'x-b']); }); it('/a/b/e -> /a/b with extra root path', async () => { await router.setRoutes( [ { path: '/a', action: elementWithAllLifecycleCallbacks('x-a'), children: [ { path: '/b', action: elementWithAllLifecycleCallbacks('x-b'), children: [ { path: '/', action: elementWithAllLifecycleCallbacks('x-b-root') }, { path: '/e', action: elementWithAllLifecycleCallbacks('x-e') }, ], }, { path: '/d', action: elementWithAllLifecycleCallbacks('x-d') }, ], }, { path: '/c', action: elementWithAllLifecycleCallbacks('x-c') }, ], true, ); callbacksLog = []; await router.render('/a/b/e'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-e.action', 'x-a.onBeforeEnter', 'x-b.onBeforeEnter', 'x-e.onBeforeEnter', 'x-a.connectedCallback', 'x-b.connectedCallback', 'x-e.connectedCallback', 'x-a.onAfterEnter', 'x-b.onAfterEnter', 'x-e.onAfterEnter', ]); checkOutlet(['x-a', 'x-b', 'x-e']); callbacksLog = []; await router.render('/a/b'); verifyCallbacks([ 'x-a.action', 'x-b.action', 'x-b-root.action', 'x-e.onBeforeLeave', 'x-b-root.onBeforeEnter', 'x-b-root.connectedCallback', 'x-b-root.onAfterEnter', 'x-e.onAfterLeave', 'x-e.disconnectedCallback', ]); checkOutlet(['x-a', 'x-b', 'x-b-root']); }); it('lifecycle events work for routes added via children function', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: () => [{ path: '/b', action: elementWithAllLifecycleCallbacks('x-b') }], }, { path: '/c', component: 'x-c' }, ], true, ); callbacksLog = []; await router.render('/a/b'); verifyCallbacks(['x-b.action', 'x-b.onBeforeEnter', 'x-b.connectedCallback', 'x-b.onAfterEnter']); callbacksLog = []; await router.render('/c'); verifyCallbacks(['x-b.onBeforeLeave', 'x-b.onAfterLeave', 'x-b.disconnectedCallback']); }); it('/users/jane/edit -> /users/john/edit (when parameters changed, callbacks for nested routes are called)', async () => { await router.setRoutes( [ { path: '/users', action: elementWithAllLifecycleCallbacks('x-users'), children: [ { path: '/:user', action: elementWithUserParameter(), children: [{ path: '/edit', action: elementWithAllLifecycleCallbacks('x-user-edit') }], }, ], }, ], true, ); await router.render('/users/jane/edit'); verifyCallbacks([ 'x-users.action', 'x-user-jane.action', 'x-user-edit.action', 'x-users.onBeforeEnter', 'x-user-jane.onBeforeEnter', 'x-user-edit.onBeforeEnter', 'x-users.connectedCallback', 'x-user-jane.connectedCallback', 'x-user-edit.connectedCallback', 'x-users.onAfterEnter', 'x-user-jane.onAfterEnter', 'x-user-edit.onAfterEnter', ]); callbacksLog = []; await router.render('/users/john/edit'); verifyCallbacks([ 'x-users.action', 'x-user-john.action', 'x-user-edit.action', 'x-user-edit.onBeforeLeave', 'x-user-jane.onBeforeLeave', 'x-user-john.onBeforeEnter', 'x-user-edit.onBeforeEnter', 'x-user-john.connectedCallback', 'x-user-edit.connectedCallback', 'x-user-john.onAfterEnter', 'x-user-edit.onAfterEnter', 'x-user-edit.onAfterLeave', 'x-user-jane.onAfterLeave', 'x-user-edit.disconnectedCallback', 'x-user-jane.disconnectedCallback', ]); }); }); describe('lifecycle events with action', () => { it('lifecycle events when reusing element (#355)', async () => { const view = document.createElement('x-spy'); view.name = 'foo'; await router.setRoutes( [ { path: '/([ab])', action: (ctx) => { callbacksLog.push(`${view.name}.action`); const content = document.createElement('div'); content.textContent = ctx.pathname; view.appendChild(content); // Returns always the same view return view; }, }, ], true, ); await router.render('/a'); const cmp = outlet.children[0] as WebComponentInterface; expect(cmp).to.be.equal(view); expect(cmp.children.length).to.be.equal(1); expect(cmp.location?.pathname).to.be.equal('/a'); expect(router.location.pathname).to.be.equal('/a'); await router.render('/b'); // Should reuse the same view expect(cmp).to.be.equal(view); // Should not modify the view content expect(cmp.children.length).to.be.equal(2); // Should update locations expect(cmp.location?.pathname).to.be.equal('/b'); expect(router.location.pathname).to.be.equal('/b'); // See #355 verifyCallbacks([ 'foo.action', 'foo.onBeforeEnter', 'foo.connectedCallback', 'foo.onAfterEnter', // always call action 'foo.action', 'foo.onBeforeLeave', 'foo.onBeforeEnter', // stop calling any other callbacks if result is the same // stop detaching/re-attaching the element ]); }); it('lifecycle events when changing first segment', async () => { const view = document.createElement('x-spy'); view.name = 'foo'; const userView = document.createElement('x-spy'); userView.name = 'x-user'; await router.setRoutes( { path: '/users/:id', action: () => { callbacksLog.push(`${userView.name}.action`); return userView; }, children: [ { path: 'edit', action: elementWithAllLifecycleCallbacks('x-edit'), }, ], }, true, ); callbacksLog = []; await router.render('/users/1/edit'); expect(outlet.children[0]).to.be.equal(userView); expect(outlet.children[0].children.length).to.be.equal(1); await router.render('/users/2/edit'); // Should reuse the same view expect(outlet.children[0]).to.be.equal(userView); verifyCallbacks([ 'x-user.action', 'x-edit.action', 'x-user.onBeforeEnter', 'x-edit.onBeforeEnter', 'x-user.connectedCallback', 'x-edit.connectedCallback', 'x-user.onAfterEnter', 'x-edit.onAfterEnter', // always call action 'x-user.action', 'x-edit.action', // only call changed segment events 'x-edit.onBeforeLeave', 'x-user.onBeforeLeave', 'x-user.onBeforeEnter', 'x-edit.onBeforeEnter', ]); }); it('lifecycle events when changing the last segment with parent layout', async () => { const view = document.createElement('x-spy'); view.name = 'x-foo'; await router.setRoutes( { path: '/', action: elementWithAllLifecycleCallbacks('x-layout'), children: [ { path: '(.*)', action: (ctx) => { callbacksLog.push(`${view.name}.action`); const content = document.createElement('div'); content.textContent = ctx.pathname; view.appendChild(content); // Returns always the same view return view; }, }, ], }, true, ); callbacksLog = []; await router.render('/b'); expect(outlet.children[0].localName).to.be.equal('x-spy'); expect(outlet.children[0].children[0]).to.be.equal(view); await router.render('/a'); // Should reuse the same view expect(outlet.children[0].children[0]).to.be.equal(view); verifyCallbacks([ 'x-layout.action', 'x-foo.action', 'x-layout.onBeforeEnter', 'x-foo.onBeforeEnter', 'x-layout.connectedCallback', 'x-foo.connectedCallback', 'x-layout.onAfterEnter', 'x-foo.onAfterEnter', // always call action 'x-layout.action', 'x-foo.action', 'x-foo.onBeforeLeave', 'x-layout.onBeforeLeave', 'x-layout.onBeforeEnter', 'x-foo.onBeforeEnter', // stop calling any other callbacks if result is the same // stop detaching/re-attaching the element ]); }); it('lifecycle when reusing element in different chains', async () => { const view = document.createElement('x-spy'); view.name = 'bar'; const action = (_: RouteContext) => { callbacksLog.push(`${view.name}.action`); // add a new div in each call to check that content is not touched const content = document.createElement('div'); view.appendChild(content); // Returns always the same view return view; }; await router.setRoutes( [ { path: '/1', component: 'foo1', children: [ { path: '/2', component: 'foo2', children: [ { path: '/a', action, }, ], }, ], }, { path: '/3', component: 'bar1', children: [ { path: '/4', component: 'bar2', children: [ { path: '/b', action, }, ], }, ], }, ], true, ); await router.render('/1/2/a'); expect(outlet.children[0].localName).to.be.equal('foo1'); expect(outlet.children[0].children[0].localName).to.be.equal('foo2'); expect(outlet.children[0].children[0].children[0]).to.be.equal(view); expect(outlet.children[0].children[0].children[0].children.length).to.be.equal(1); await router.render('/3/4/b'); expect(outlet.children[0].localName).to.be.equal('bar1'); expect(outlet.children[0].children[0].localName).to.be.equal('bar2'); // Should reuse the same view expect(outlet.children[0].children[0].children[0]).to.be.equal(view); // Should not modify the view content expect(outlet.children[0].children[0].children[0].children.length).to.be.equal(2); verifyCallbacks([ 'bar.action', 'bar.onBeforeEnter', 'bar.connectedCallback', 'bar.onAfterEnter', 'bar.action', 'bar.onBeforeLeave', 'bar.onBeforeEnter', 'bar.disconnectedCallback', 'bar.connectedCallback', 'bar.onAfterEnter', 'bar.onAfterLeave', ]); }); it('lifecycle events for dynamic chains', async () => { const view = document.createElement('x-spy'); view.name = 'x-spy'; const action = (ctx: RouteContext) => { callbacksLog.push(`${view.name}.action`); // add a new div in each call to check that content is not touched const content = document.createElement('div'); content.textContent = ctx.pathname; view.appendChild(content); // Returns always the same view return view; }; await router.setRoutes( [ { path: '/users/:name/', action: (ctx) => document.createElement(`user-${String(ctx.params.name)}`), children: [ { path: 'edit', action }, { path: 'profile', action }, ], }, ], true, ); await router.render('/users/bunny/profile'); expect(outlet.children[0].localName).to.be.equal('user-bunny'); expect(outlet.children[0].children[0]).to.be.equal(view); expect(outlet.children[0].children[0].children.length).to.be.equal(1); await router.render('/users/donald/edit'); expect(outlet.children[0].localName).to.be.equal('user-donald'); expect(outlet.children[0].children[0]).to.be.equal(view); expect(outlet.children[0].children[0].children.length).to.be.equal(2); verifyCallbacks([ 'x-spy.action', 'x-spy.onBeforeEnter', 'x-spy.connectedCallback', 'x-spy.onAfterEnter', 'x-spy.action', 'x-spy.onBeforeLeave', 'x-spy.onBeforeEnter', 'x-spy.disconnectedCallback', 'x-spy.connectedCallback', 'x-spy.onAfterEnter', 'x-spy.onAfterLeave', ]); }); it('lifecycle events for the same route when not reusing element (#361)', async () => { const view1 = document.createElement('x-spy'); view1.textContent = 'view1'; view1.name = 'view1'; const view2 = document.createElement('x-spy'); view2.textContent = 'view2'; view2.name = 'view2'; let cont = 0; await router.setRoutes( [ { path: '/a', action: (_: RouteContext) => { const view = cont % 2 ? view2 : view1; cont += 1; callbacksLog.push(`${view.name}.action`); const content = document.createElement('div'); content.textContent = `content-${view.name}`; view.appendChild(content); return view; }, }, ], true, ); await router.render('/a'); expect(outlet.children[0]).to.be.equal(view1); expect(outlet.children[0].children.length).to.be.equal(1); expect(outlet.children[0].children[0].textContent).to.be.equal('content-view1'); await router.render('/a'); expect(outlet.children[0]).to.be.equal(view2); expect(outlet.children[0].children.length).to.be.equal(1); expect(outlet.children[0].children[0].textContent).to.be.equal('content-view2'); verifyCallbacks([ 'view1.action', 'view1.onBeforeEnter', 'view1.connectedCallback', 'view1.onAfterEnter', 'view2.action', 'view1.onBeforeLeave', 'view2.onBeforeEnter', 'view2.connectedCallback', 'view2.onAfterEnter', 'view1.onAfterLeave', 'view1.disconnectedCallback', ]); }); it('Make lifecycle callbacks when reusing element for same path (#362, #311, #331)', async () => { const view = document.createElement('x-spy'); view.textContent = 'view'; view.name = 'view'; let cont = 0; await router.setRoutes( [ { path: '/a', action: (_: RouteContext) => { callbacksLog.push(`${view.name}.action.${cont % 2}`); const content = document.createElement('div'); content.textContent = `content-${view.name}-${cont % 2}`; cont += 1; view.appendChild(content); return view; }, }, ], true, ); await router.render('/a'); expect(outlet.children[0]).to.be.equal(view); expect(outlet.children[0].children.length).to.be.equal(1); expect(outlet.children[0].children[0].textContent).to.be.equal('content-view-0'); await router.render('/a'); expect(outlet.children[0]).to.be.equal(view); expect(outlet.children[0].children.length).to.be.equal(2); // #362 expect(outlet.children[0].children[0].textContent).to.be.equal('content-view-0'); expect(outlet.children[0].children[1].textContent).to.be.equal('content-view-1'); verifyCallbacks([ 'view.action.0', 'view.onBeforeEnter', 'view.connectedCallback', 'view.onAfterEnter', // Action is always called 'view.action.1', 'view.onBeforeLeave', 'view.onBeforeEnter', ]); }); it('do not reattach component for same path (#311, #331)', async () => { await router.setRoutes([{ path: '/a', component: 'x-spy' }], true); await router.render('/a'); verifyCallbacks([ // actions are not logged because using components 'x-spy.onBeforeEnter', 'x-spy.connectedCallback', 'x-spy.onAfterEnter', ]); const [view] = outlet.children; callbacksLog = []; await router.render('/a'); expect(outlet.children[0]).to.be.equal(view); // Skip detach/re-attach and notifications (#311 #331) verifyCallbacks(['x-spy.onBeforeLeave', 'x-spy.onBeforeEnter']); }); it('should update previousContext when attach is skipped (#391)', async () => { const container = document.createElement('x-spy'); const layout = document.createElement('div'); await router.setRoutes( [ { path: '/', action: () => layout, children: [ { path: '(.*)', action: (ctx) => { container.name = `${ctx.pathname}-container`; callbacksLog.push(`${container.name}.action`); return container; }, }, ], } satisfies Route, ], true, ); callbacksLog = []; await router.render('/server1'); verifyCallbacks([ '/server1-container.action', '/server1-container.onBeforeEnter', '/server1-container.connectedCallback', '/server1-container.onAfterEnter', ]); callbacksLog = []; await router.render('/server2'); verifyCallbacks([ '/server2-container.action', '/server2-container.onBeforeLeave', '/server2-container.onBeforeEnter', ]); // This fails if previousContext is not updated (#391) callbacksLog = []; await router.render('/server1'); verifyCallbacks([ '/server1-container.action', '/server1-container.onBeforeLeave', '/server1-container.onBeforeEnter', ]); }); it('should not remove layout contents when it is reused (#392)', async () => { // A reusable layout with some content const layout = document.createElement('span'); const layoutContent = document.createElement('a'); layoutContent.textContent = 'layout-link'; layout.appendChild(layoutContent); // Two different reusable views for client and server routes const clientContainer = document.createElement('h1'); clientContainer.textContent = 'client'; const serverContainer = document.createElement('h2'); serverContainer.textContent = 'server'; await router.setRoutes( [ { path: '/', action: () => layout, children: [ { path: 'client', action: () => clientContainer, }, { path: 'server', action: () => serverContainer, }, ], }, ], true, ); await router.render('/server'); expect(outlet.innerHTML.toLowerCase()).to.be.equal('layout-link

server

'); await router.render('/client'); expect(outlet.innerHTML.toLowerCase()).to.be.equal('layout-link

client

'); }); // https://github.com/vaadin/flow/issues/8081 it('should keep lifecycle even when path remains same and search string remains empty', async () => { await router.setRoutes([{ path: '/a', component: 'x-spy' }], true); // Pathname only means empty search string await router.render('/a'); const [view] = outlet.children; callbacksLog = []; // No search in context means empty search string await router.render({ pathname: '/a' }); expect(outlet.children[0]).to.be.equal(view); verifyCallbacks(['x-spy.onBeforeLeave', 'x-spy.onBeforeEnter']); // Explicit empty search string await router.render({ pathname: '/a', search: '' }); expect(outlet.children[0]).to.be.equal(view); // Search remains empty, stil lifecycle verifyCallbacks(['x-spy.onBeforeLeave', 'x-spy.onBeforeEnter', 'x-spy.onBeforeLeave', 'x-spy.onBeforeEnter']); }); it('should call lifecycle when path remains same and search string changes', async () => { await router.setRoutes([{ path: '/a', component: 'x-spy' }], true); // Pathname only means empty search string await router.render('/a'); const [view] = outlet.children; callbacksLog = []; await router.render({ pathname: '/a', search: '?foo=bar' }); expect(outlet.children[0]).to.be.equal(view); // Search string changed, call short lifecycle without reattach verifyCallbacks(['x-spy.onBeforeLeave', 'x-spy.onBeforeEnter']); callbacksLog = []; await router.render({ pathname: '/a', search: '?foo=baz' }); expect(outlet.children[0]).to.be.equal(view); // Search string changed again, call short lifecycle without reattach verifyCallbacks(['x-spy.onBeforeLeave', 'x-spy.onBeforeEnter']); }); }); describe('lifecycle events with async action', () => { it('should invoke lifecycle events after action promise resolves', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/x-spy', async action() { return await new Promise((resolve) => { setTimeout(() => { callbacksLog.push('action.promise'); resolve(undefined); }, 100); }); }, component: 'x-spy', }, ], true, ); await router.render('/'); callbacksLog = []; await router.render('/x-spy'); verifyCallbacks(['action.promise', 'x-spy.onBeforeEnter', 'x-spy.connectedCallback', 'x-spy.onAfterEnter']); }); async function registerSpyComponentAsync(tagname: string, name: string, delayms: number): Promise { return await new Promise((resolve) => { setTimeout(() => { callbacksLog.push(`${name}.define`); window.customElements.define( tagname, class extends XSpy { constructor() { super(); this.name = name; } }, ); resolve(); }, delayms); }); } it('should invoke lifecycle events for dynamically imported routes sequentially', async () => { const unique = Math.floor(Math.random() * 100000); const parentTagname = `x-parent-layout-${unique}`; const childTagname = `x-child-${unique}`; await router.setRoutes( [ { path: '/a', component: parentTagname, async action() { callbacksLog.push(`x-parent-layout.action`); await registerSpyComponentAsync(parentTagname, 'x-parent-layout', 30); return undefined; }, children: [ { path: '/b', component: childTagname, async action() { callbacksLog.push(`x-child.action`); await registerSpyComponentAsync(childTagname, 'x-child', 30); return undefined; }, }, ], }, ], true, ); await router.render('/a/b'); verifyCallbacks([ `x-parent-layout.action`, `x-parent-layout.define`, `x-child.action`, `x-child.define`, `x-parent-layout.onBeforeEnter`, `x-child.onBeforeEnter`, `x-parent-layout.connectedCallback`, `x-child.connectedCallback`, `x-parent-layout.onAfterEnter`, `x-child.onAfterEnter`, ]); }); }); describe('the global `vaadin-router-location-changed` event', () => { it('should be triggered after a completed navigation', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const onRouteChanged = sinon.spy(); window.addEventListener('vaadin-router-location-changed', onRouteChanged); await router.render('/'); window.removeEventListener('vaadin-router-location-changed', onRouteChanged); expect(onRouteChanged).to.have.been.calledOnce; }); it('should NOT be triggered after an abandoned navigation', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/admin', component: 'x-admin-view' }, ], true, ); const onRouteChanged = sinon.spy(); window.addEventListener('vaadin-router-location-changed', onRouteChanged); await Promise.all([router.render('/'), router.render('/admin')]); window.removeEventListener('vaadin-router-location-changed', onRouteChanged); expect(onRouteChanged).to.have.been.calledOnce; }); it('should contain the new location as `event.detail.location`', async () => { await router.setRoutes([{ path: '/admin', component: 'x-admin-view' }], true); const onRouteChanged = sinon.spy(); window.addEventListener('vaadin-router-location-changed', onRouteChanged); await router.render('/admin'); window.removeEventListener('vaadin-router-location-changed', onRouteChanged); expect(onRouteChanged).to.have.been.calledOnce; expect(onRouteChanged.firstCall.args.length).to.equal(1); const event: VaadinRouterLocationChangedEvent = onRouteChanged.firstCall.firstArg; expect(event.detail.location).to.equal(router.location); }); it('should contain the router instance as `event.detail.router`', async () => { await router.setRoutes([{ path: '/admin', component: 'x-admin-view' }], true); const onRouteChanged = sinon.spy(); window.addEventListener('vaadin-router-location-changed', onRouteChanged); await router.render('/admin'); window.removeEventListener('vaadin-router-location-changed', onRouteChanged); expect(onRouteChanged).to.have.been.calledOnce; expect(onRouteChanged.firstCall.args.length).to.equal(1); const event: VaadinRouterLocationChangedEvent = onRouteChanged.firstCall.firstArg; expect(event.detail.router).to.equal(router); }); it('should be triggered after location update', async () => { await router.setRoutes([{ path: '/admin', component: 'x-admin-view' }], true); let pathname; const checkLocation = () => { expect(router).to.have.nested.property('location.pathname', '/admin'); ({ pathname } = window.location); }; window.addEventListener('vaadin-router-location-changed', checkLocation); await router.render('/admin', true); window.removeEventListener('vaadin-router-location-changed', checkLocation); expect(pathname).to.equal('/admin'); }); }); describe('the global `vaadin-router-error` event', () => { it('should be triggered after a failed navigation', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const onError = sinon.spy(); window.addEventListener('vaadin-router-error', onError); await router.render('/non-existent').catch(() => {}); window.removeEventListener('vaadin-router-error', onError); expect(onError).to.have.been.calledOnce; }); it('should NOT be triggered after an abandoned navigation', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const onError = sinon.spy(); window.addEventListener('vaadin-router-error', onError); await Promise.all([ router.render('/non-existent-1').catch(() => {}), router.render('/non-existent-2').catch(() => {}), ]); window.removeEventListener('vaadin-router-error', onError); expect(onError).to.have.been.calledOnce; }); it('should contain the error as `event.detail.error`', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const onError = sinon.spy(); window.addEventListener('vaadin-router-error', onError); await router.render('/non-existent').catch(() => {}); window.removeEventListener('vaadin-router-error', onError); expect(onError).to.have.been.calledOnce; expect(onError.firstCall.args.length).to.equal(1); const event: VaadinRouterErrorEvent = onError.firstCall.firstArg; expect(event.detail.error).to.be.an('error'); expect(event.detail.error.context.pathname).to.equal('/non-existent'); }); it('should contain the router instance as `event.detail.router`', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const onError = sinon.spy(); window.addEventListener('vaadin-router-error', onError); await router.render('/non-existent').catch(() => {}); window.removeEventListener('vaadin-router-error', onError); expect(onError).to.have.been.calledOnce; expect(onError.firstCall.args.length).to.equal(1); const event: VaadinRouterErrorEvent = onError.firstCall.firstArg; expect(event.detail.router).to.equal(router); }); it('should contain the failed pathname as `event.detail.pathname`', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const onError = sinon.spy(); window.addEventListener('vaadin-router-error', onError); await router.render('/non-existent').catch(() => {}); window.removeEventListener('vaadin-router-error', onError); expect(onError).to.have.been.calledOnce; expect(onError.firstCall.args.length).to.equal(1); const event: VaadinRouterErrorEvent = onError.firstCall.firstArg; expect(event.detail.pathname).to.equal('/non-existent'); }); }); describe('Simultaneous renders', () => { const PAUSE_TIME = 100; // in ms const elementWithAction = (elementName: string) => { callbacksLog.push(`${elementName}.action`); const el = document.createElement('x-spy'); el.name = elementName; return el; }; const elementWithSlowBeforeEnter = (elementName: string) => (context: RouteContext) => { const el = elementWithAction(`${elementName}-render-${context.__renderId}`); el.onBeforeEnter = async () => { callbacksLog.push(`${el.name}.onBeforeEnter`); await sleep(PAUSE_TIME); return undefined; }; return el; }; const elementWithSlowBeforeLeave = (elementName: string) => (context: RouteContext) => { const el = elementWithAction(`${elementName}-render-${context.__renderId}`); el.onBeforeLeave = async () => { callbacksLog.push(`${el.name}.onBeforeLeave`); await sleep(PAUSE_TIME); }; return el; }; const elementWithRenderId = (elementName: string) => (context: RouteContext) => elementWithAction(`${elementName}-render-${context.__renderId}`); it('should only run action when it is the last render', async () => { await router.setRoutes( [ { path: '/', async action(context: RouteContext) { const el = elementWithAction(`x-parent-layout-render-${context.__renderId}`); await sleep(PAUSE_TIME); return el; }, children: [ { path: 'a', action: (context: RouteContext) => elementWithAction(`x-a-render-${context.__renderId}`), }, { path: 'b', action: (context: RouteContext) => elementWithAction(`x-b-render-${context.__renderId}`), }, ], }, ], true, ); callbacksLog = []; // eslint-disable-next-line no-void void router.render('/a'); // render another path just before it runs action of `a` await sleep(PAUSE_TIME * 0.9); await router.render('/b'); verifyCallbacks([ 'x-parent-layout-render-1.action', 'x-parent-layout-render-2.action', 'x-b-render-2.action', 'x-parent-layout-render-2.onBeforeEnter', 'x-b-render-2.onBeforeEnter', 'x-parent-layout-render-2.connectedCallback', 'x-b-render-2.connectedCallback', 'x-parent-layout-render-2.onAfterEnter', 'x-b-render-2.onAfterEnter', ]); }); it('should only run onBeforeEnter events when it is the last render', async () => { await router.setRoutes( [ { path: '/', action: elementWithSlowBeforeEnter('x-parent-layout'), children: [ { path: 'a', action: elementWithSlowBeforeEnter('x-a'), }, { path: 'b', action: elementWithSlowBeforeEnter('x-b'), }, ], }, ], true, ); callbacksLog = []; // eslint-disable-next-line no-void void router.render('/a'); // wait until the end of parent.onBeforeEnter // then trigger a new render // so that `x-a.onBeforeEnter` won't be executed await sleep(PAUSE_TIME * 0.9); await router.render('/b'); await router.ready; verifyCallbacks([ 'x-parent-layout-render-1.action', 'x-a-render-1.action', 'x-parent-layout-render-1.onBeforeEnter', 'x-parent-layout-render-2.action', 'x-b-render-2.action', 'x-parent-layout-render-2.onBeforeEnter', 'x-b-render-2.onBeforeEnter', 'x-parent-layout-render-2.connectedCallback', 'x-b-render-2.connectedCallback', 'x-parent-layout-render-2.onAfterEnter', 'x-b-render-2.onAfterEnter', ]); }); it('should stop running onBeforeEnter events immediately when there is a new render', async () => { await router.setRoutes( [ { path: '/', action: elementWithSlowBeforeEnter('x-parent-layout'), children: [ { path: 'a', action: elementWithSlowBeforeEnter('x-a'), children: [ { path: 'a-child', action: elementWithSlowBeforeEnter('x-a-child'), }, ], }, { path: 'b', action: elementWithSlowBeforeEnter('x-b'), }, ], }, ], true, ); callbacksLog = []; // eslint-disable-next-line no-void void router.render('/a/a-child'); // give it enough time for running `parent.onBeforeEnter` and `x-a.onBeforeEnter` // then start a new render, // so `a-child.onBeforeEnter` shouldn't run at all await sleep(PAUSE_TIME * 1.5); await router.render('/b'); verifyCallbacks([ 'x-parent-layout-render-1.action', 'x-a-render-1.action', 'x-a-child-render-1.action', 'x-parent-layout-render-1.onBeforeEnter', 'x-a-render-1.onBeforeEnter', 'x-parent-layout-render-2.action', 'x-b-render-2.action', 'x-parent-layout-render-2.onBeforeEnter', 'x-b-render-2.onBeforeEnter', 'x-parent-layout-render-2.connectedCallback', 'x-b-render-2.connectedCallback', 'x-parent-layout-render-2.onAfterEnter', 'x-b-render-2.onAfterEnter', ]); }); it('should only run onBeforeLeave events when it is the last render', async () => { await router.setRoutes( [ { path: '/', action: elementWithSlowBeforeLeave('x-parent-layout'), children: [ { path: 'a', action: elementWithSlowBeforeLeave('x-a'), children: [ { path: 'a-child', action: elementWithSlowBeforeLeave('x-a-child'), }, ], }, { path: 'b', action: elementWithSlowBeforeLeave('x-b'), }, ], }, ], true, ); // eslint-disable-next-line no-void await router.render('/a/a-child'); callbacksLog = []; // eslint-disable-next-line no-void void router.render('/b'); await sleep(PAUSE_TIME * 0.9); await router.render('/a/a-child'); verifyActiveRoutes(router, ['/', 'a', 'a-child']); verifyCallbacks([ 'x-parent-layout-render-2.action', 'x-b-render-2.action', 'x-a-child-render-1.onBeforeLeave', 'x-parent-layout-render-3.action', 'x-a-render-3.action', 'x-a-child-render-3.action', 'x-a-child-render-1.onBeforeLeave', 'x-a-render-1.onBeforeLeave', 'x-parent-layout-render-1.onBeforeLeave', 'x-parent-layout-render-3.onBeforeEnter', 'x-a-render-3.onBeforeEnter', 'x-a-child-render-3.onBeforeEnter', 'x-parent-layout-render-3.connectedCallback', 'x-a-render-3.connectedCallback', 'x-a-child-render-3.connectedCallback', 'x-parent-layout-render-3.onAfterEnter', 'x-a-render-3.onAfterEnter', 'x-a-child-render-3.onAfterEnter', 'x-a-child-render-1.onAfterLeave', 'x-a-render-1.onAfterLeave', 'x-parent-layout-render-1.onAfterLeave', 'x-a-render-1.disconnectedCallback', 'x-a-child-render-1.disconnectedCallback', 'x-parent-layout-render-1.disconnectedCallback', ]); }); it('should only run onAfterEnter/onAfterLeave events when it is the last render', async () => { await router.setRoutes( [ { path: '/', action: elementWithRenderId('x-parent-layout'), children: [ { path: 'a', action: elementWithRenderId('x-a'), children: [ { path: 'a-child', action: elementWithRenderId('x-a-child'), }, ], }, { path: 'b', action: elementWithRenderId('x-b'), }, ], }, ], true, ); const waitForLocationPromise = new Promise((resolve, reject) => { const ctrl = new AbortController(); // Attach a listener to `location-changed` event to trigger another render // because the event happens just before 'onAfterEnter'/'onAfterLeave'. window.addEventListener( 'vaadin-router-location-changed', (event: VaadinRouterLocationChangedEvent) => { if (event.detail.location.pathname === '/b') { ctrl.abort(); router .render('/a/a-child') .then(() => { verifyActiveRoutes(router, ['/', 'a', 'a-child']); verifyCallbacks([ 'x-parent-layout-render-2.action', 'x-b-render-2.action', 'x-a-render-1.onBeforeLeave', 'x-parent-layout-render-1.onBeforeLeave', 'x-parent-layout-render-2.onBeforeEnter', 'x-b-render-2.onBeforeEnter', 'x-parent-layout-render-2.connectedCallback', 'x-b-render-2.connectedCallback', // x-b-render-2.onAfterEnter is not executed here // because the 3rd render already started 'x-parent-layout-render-3.action', 'x-a-render-3.action', 'x-a-child-render-3.action', 'x-a-render-1.onBeforeLeave', 'x-parent-layout-render-1.onBeforeLeave', 'x-parent-layout-render-3.onBeforeEnter', 'x-a-render-3.onBeforeEnter', 'x-a-child-render-3.onBeforeEnter', 'x-parent-layout-render-2.disconnectedCallback', 'x-b-render-2.disconnectedCallback', 'x-parent-layout-render-3.connectedCallback', 'x-a-render-3.connectedCallback', 'x-a-child-render-3.connectedCallback', 'x-parent-layout-render-3.onAfterEnter', 'x-a-render-3.onAfterEnter', 'x-a-child-render-3.onAfterEnter', 'x-a-render-1.onAfterLeave', 'x-parent-layout-render-1.onAfterLeave', 'x-a-render-1.disconnectedCallback', 'x-parent-layout-render-1.disconnectedCallback', ]); resolve(); }) .catch((e: unknown) => { reject(e); }); } }, { signal: ctrl.signal }, ); }); await router.render('/a'); callbacksLog = []; // eslint-disable-next-line no-void void router.render('/b'); await waitForLocationPromise; }); }); }); ================================================ FILE: test/router/parent-layout.spec.ts ================================================ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; import { Router } from '../../src/router.js'; import type { ResolutionError, RouterLocation, WebComponentInterface } from '../../src/types.t.js'; import '../setup.js'; import { checkOutletContents, cleanup, onAfterEnterAction, onBeforeEnterAction, onBeforeLeaveAction, verifyActiveRoutes, } from './test-utils.js'; describe('Router', () => { let outlet: HTMLElement; let router: Router; before(() => { outlet = document.createElement('div'); document.body.append(outlet); }); after(() => { outlet.remove(); }); beforeEach(() => { // create a new router instance router = new Router(outlet); }); afterEach(() => { router.unsubscribe(); cleanup(outlet); }); describe('parent layouts rendering', () => { const checkOutlet = (values: readonly string[]) => checkOutletContents(outlet.lastChild as Element, 'tagName', values); it('each of the nested route components are rendered as children to each other in the same hierarchy', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [{ path: '/b', component: 'x-b', children: [{ path: '/c', component: 'x-c' }] }], }, ], true, ); await router.render('/a/b/c'); verifyActiveRoutes(router, ['/a', '/b', '/c']); checkOutlet(['x-a', 'x-b', 'x-c']); }); it('should preserve references to same DOM node and reuse it on subsequent renders', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [ { path: '/b', component: 'x-b' }, { path: '/c', component: 'x-c' }, { path: '/d', component: 'x-d' }, ], }, ], true, ); await router.render('/a/b'); const first = outlet.lastElementChild; expect(first).to.be.not.null; expect(first!.firstElementChild).to.have.property('localName').that.matches(/x-b/iu); await router.render('/a/c'); const second = outlet.lastElementChild; expect(second).to.equal(first); expect(second!.firstElementChild).to.have.property('localName').that.matches(/x-c/iu); await router.render('/a/d'); const third = outlet.lastElementChild; expect(third).to.equal(second); expect(third!.firstElementChild).to.have.property('localName').that.matches(/x-d/iu); }); it('should update parent location when reusing layout', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [ { path: '/b', component: 'x-b' }, { path: '/c', component: 'x-c' }, { path: '/([de])', component: 'x-d' }, ], }, ], true, ); await router.render('/a/b'); expect((outlet.lastElementChild as WebComponentInterface).location) .to.have.property('pathname') .that.equals('/a/b'); await router.render('/a/c'); expect((outlet.lastElementChild as WebComponentInterface).location) .to.have.property('pathname') .that.equals('/a/c'); await router.render('/a/d'); expect((outlet.lastElementChild as WebComponentInterface).location) .to.have.property('pathname') .that.equals('/a/d'); await router.render('/a/e'); expect((outlet.lastElementChild as WebComponentInterface).location) .to.have.property('pathname') .that.equals('/a/e'); }); it('should remove nested route components when the parent route is navigated to', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [{ path: '/b', component: 'x-b' }] }, { path: '/c', component: 'x-c' }, ], true, ); await router.render('/a/b'); await router.render('/c'); await router.render('/a'); verifyActiveRoutes(router, ['/a']); checkOutlet(['x-a']); }); it('when action returns a component result, it is rendered the same way as if it was a component property', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [ { path: '/b', action: (_context, commands) => commands.component('x-b'), children: [{ path: '/c', action: (_context, commands) => commands.component('x-c') }], }, ], }, ], true, ); await router.render('/a/b/c'); verifyActiveRoutes(router, ['/a', '/b', '/c']); checkOutlet(['x-a', 'x-b', 'x-c']); }); it('extra child view in route chain is not rendered, if path does not match', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [ { path: '/b', component: 'x-b', children: [{ path: '/c', component: 'x-c', children: [{ path: '/d', component: 'x-d' }] }], }, ], }, ], true, ); await router.render('/a/b/c'); verifyActiveRoutes(router, ['/a', '/b', '/c']); checkOutlet(['x-a', 'x-b', 'x-c']); }); it('should not render the root component, if path does not match', async () => { await router.setRoutes([{ path: '/', component: 'x-root', children: [{ path: '/a', component: 'x-a' }] }], true); let exception; await router.render('/c').catch((e: unknown) => { exception = e; }); expect(exception, 'No exception thrown for not matched route /c').to.be.instanceof(Error); }); it('should allow parent route paths with trailing slashes', async () => { await router.setRoutes( [ { path: '/', component: 'x-root' }, { path: '/a/', component: 'x-a', children: [ { path: '/b', component: 'x-b' }, { path: '(.+)', component: 'x-any' }, ], }, ], true, ); await router.render('/'); checkOutlet(['x-root']); await router.render('/a/'); checkOutlet(['x-a']); await router.render('/a/b'); checkOutlet(['x-a', 'x-b']); }); it( 'when not all nested views have components, all present components are rendered as children ' + 'to each other in the same hierarchy', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [{ path: '/b', children: [{ path: '/c', children: [{ path: '/d', component: 'x-d' }] }] }], }, ], true, ); await router.render('/a/b/c/d'); verifyActiveRoutes(router, ['/a', '/b', '/c', '/d']); checkOutlet(['x-a', 'x-d']); }, ); it('should take next routes as fallback when children do not match', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [{ path: '/b', component: 'x-b' }] }, { path: '/a/c', component: 'x-fallback' }, ], true, ); await router.render('/a/c'); verifyActiveRoutes(router, ['/a/c']); checkOutlet(['x-fallback']); }); it('should take next routes as fallback when grandchildren do not match', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [{ path: '/b', component: 'x-b', children: [{ path: '/b', component: 'x-c' }] }], }, { path: '/a/b/d', component: 'x-fallback' }, ], true, ); await router.render('/a/b/d'); verifyActiveRoutes(router, ['/a/b/d']); checkOutlet(['x-fallback']); }); it('should throw not found when neither children nor siblings match', async () => { // Ensure outlet is clean const childNodes = Array.from(outlet.childNodes); while (outlet.lastChild != null) { outlet.removeChild(outlet.lastChild); } await router.setRoutes( [ { path: '/a', component: 'x-a', children: [{ path: '/b', component: 'x-b', children: [{ path: '/b', component: 'x-c' }] }], }, { path: '/a/b/d', component: 'x-fallback' }, ], true, ); const onError = sinon.spy((_: unknown) => {}); await router.render('/a/b/e').catch(onError); expect(outlet.childNodes.length).to.equal(0); const error: ResolutionError = onError.firstCall.firstArg; expect(error).to.be.an('error'); expect(error.message).to.match(/page not found/iu); // Restore previous outlet content // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (outlet.lastChild != null) { outlet.removeChild(outlet.lastChild); } childNodes.forEach((childNode) => outlet.appendChild(childNode)); }); it('should render the matching child route even if it is not under the first matching parent', async () => { await router.setRoutes( [ { path: '/', component: 'x-layout-a', children: [{ path: '/a', component: 'x-a' }], }, { path: '/', component: 'x-layout-b', children: [{ path: '/b', component: 'x-b' }], }, ], true, ); await router.render('/b'); verifyActiveRoutes(router, ['/', '/b']); checkOutlet(['x-layout-b', 'x-b']); }); it('redirect property amends previous path', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [{ path: '/b', component: 'x-b', children: [{ path: '/c', component: 'x-c', redirect: '/d' }] }], }, { path: '/d', component: 'x-d', children: [{ path: '/e', component: 'x-e' }] }, ], true, ); await router.render('/a/b/c'); verifyActiveRoutes(router, ['/d']); checkOutlet(['x-d']); }); it('action with redirect result amends previous path', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [ { path: '/b', action: (_context, commands) => commands.redirect('/d/e'), component: 'x-b', children: [{ path: '/c', component: 'x-c' }], }, ], }, { path: '/d', component: 'x-d', children: [{ path: '/e', component: 'x-e' }] }, ], true, ); await router.render('/a/b/c'); verifyActiveRoutes(router, ['/d', '/e']); checkOutlet(['x-d', 'x-e']); }); it('child layout: onAfterEnter should receive correct route parameters', async () => { const onAfterEnter = sinon.spy(); await router.setRoutes( [ { path: '/a', component: 'x-a', children: [{ path: '/b/:id', action: onAfterEnterAction('x-b', onAfterEnter) }], }, ], true, ); await router.render('/a/b/123'); expect(onAfterEnter).to.have.been.calledOnce; expect(onAfterEnter.args[0].length).to.equal(3); const location: RouterLocation = onAfterEnter.firstCall.firstArg; expect(location.pathname).to.equal('/a/b/123'); expect(location.route?.path).to.equal('/b/:id'); expect(location.params).to.have.property('id', '123'); verifyActiveRoutes(router, ['/a', '/b/:id']); checkOutlet(['x-a', 'x-b']); }); it('child layout: onBeforeEnter with redirect result amends previous path', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [ { path: '/b', action: onBeforeEnterAction('x-b', (_location, commands) => commands.redirect('/d/e')), children: [{ path: '/c', component: 'x-c' }], }, ], }, { path: '/d', component: 'x-d', children: [{ path: '/e', component: 'x-e' }] }, ], true, ); await router.render('/a/b/c'); verifyActiveRoutes(router, ['/d', '/e']); checkOutlet(['x-d', 'x-e']); }); it('child layout: onBeforeEnter with cancel result aborts current resolution', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [{ path: '/b', component: 'x-b', children: [{ path: '/c', component: 'x-c' }] }], }, { path: '/d', action: onBeforeEnterAction('x-d', (_location, commands) => commands.prevent()), children: [{ path: '/e', component: 'x-e' }], }, ], true, ); await router.render('/a/b/c'); await router.render('/d/e'); verifyActiveRoutes(router, ['/a', '/b', '/c']); checkOutlet(['x-a', 'x-b', 'x-c']); }); it('child layout: onBeforeLeave with cancel result aborts current resolution', async () => { await router.setRoutes( [ { path: '/a', component: 'x-a', children: [ { path: '/b', action: onBeforeLeaveAction('x-b', (_location, commands) => commands.prevent()), children: [{ path: '/c', component: 'x-c' }], }, ], }, { path: '/d', component: 'x-d', children: [{ path: '/e', component: 'x-e' }] }, ], true, ); await router.render('/a/b/c'); await router.render('/d/e'); verifyActiveRoutes(router, ['/a', '/b', '/c']); checkOutlet(['x-a', 'x-b', 'x-c']); }); }); }); ================================================ FILE: test/router/router.spec.ts ================================================ /* eslint-disable no-await-in-loop */ import { expect, use } from '@esm-bundle/chai'; import chaiAsPromised from 'chai-as-promised'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { Router } from '../../src/router.js'; import type { ChildrenCallback, Commands, Route, RouteChildrenContext, RouteContext, WebComponentInterface, } from '../../src/types.t.js'; import '../setup.js'; import { checkOutletContents, cleanup, onBeforeEnterAction } from './test-utils.js'; use(sinonChai); use(chaiAsPromised); async function expectException(callback: Promise, expectedContentsArray?: readonly string[]) { let exceptionThrown = false; try { await callback; } catch (e: unknown) { exceptionThrown = true; if (expectedContentsArray?.length) { const exceptionString = e instanceof Error ? e.message : JSON.stringify(e); for (const expectedContent of expectedContentsArray) { expect(exceptionString).to.contain(expectedContent); } } } expect(exceptionThrown).to.equal(true); } describe('Router', () => { let outlet: HTMLElement; let link: HTMLAnchorElement; const checkOutlet = (values: readonly string[]) => checkOutletContents(outlet.children[0], 'tagName', values); before(() => { link = document.createElement('a'); link.href = '/admin'; link.id = 'admin-anchor'; outlet = document.createElement('div'); document.body.append(link, outlet); history.pushState(null, '', '/'); }); beforeEach(() => { history.replaceState(null, '', '/'); }); after(() => { outlet.remove(); history.back(); }); afterEach(() => { cleanup(outlet); }); describe('JS API (basic functionality)', () => { let router: Router; afterEach(() => { router.unsubscribe(); }); describe('new Router(outlet?, options?)', () => { it('should work without arguments', () => { router = new Router(); expect(router).to.be.ok; }); it('should accept a router outlet DOM Node as the 1st argument', () => { router = new Router(outlet); const actual = router.getOutlet(); expect(actual).to.equal(outlet); }); it('should throw if the router outlet is truthy but is not valid a DOM Node', () => { [true, 42, '', {}, [document.body], () => document.body].forEach((arg) => { // @ts-expect-error: testing invalid arguments // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions expect(() => new Router(arg), `${arg}`).to.throw(TypeError); }); }); it('route should throw when created with only path property', async () => { router = new Router(outlet); // @ts-expect-error route is missing required properties, expecting runtime error await expect(router.setRoutes([{ path: '/' }], true)).to.be.rejectedWith(Error, / either/iu); }); it('should not fail silently if not configured (both routes and outlet missing)', async () => { router = new Router(); link.click(); await expect(router.ready).to.be.rejectedWith(Error, /page not found/iu); }); it('should not fail silently if not configured (outlet is set but routes are missing)', async () => { router = new Router(outlet); link.click(); await expect(router.ready).to.be.rejectedWith(Error, /page not found/iu); }); }); describe('baseUrl', () => { const baseElement = document.createElement('base'); beforeEach(() => { baseElement.removeAttribute('href'); document.head.appendChild(baseElement); }); afterEach(() => { document.head.removeChild(baseElement); }); it('should accept baseUrl in options object as the 2nd argument', () => { router = new Router(null, { baseUrl: '/users/' }); expect(router).to.have.property('baseUrl', '/users/'); }); it('should use as default baseUrl', () => { baseElement.setAttribute('href', '/foo/'); router = new Router(null); expect(router).to.have.property('baseUrl', `${location.origin}/foo/`); }); it('should resolve relative base href when setting baseUrl', () => { baseElement.setAttribute('href', './foo/../bar/asdf'); router = new Router(null); expect(router).to.have.property('baseUrl', `${location.origin}/bar/`); }); it('should use absolute base href when setting baseUrl', () => { baseElement.setAttribute('href', '/my/base/'); router = new Router(null); expect(router).to.have.property('baseUrl', `${location.origin}/my/base/`); }); it('should use custom base href when setting baseUrl', () => { baseElement.setAttribute('href', 'http://localhost:8080/my/custom/base/'); router = new Router(null); expect(router).to.have.property('baseUrl', 'http://localhost:8080/my/custom/base/'); }); it('should use baseUrl when matching relative routes', async () => { router = new Router(outlet, { baseUrl: '/foo/' }); await router.setRoutes([{ path: 'home', component: 'x-home-view' }], true); await router.render('/foo/home'); checkOutlet(['x-home-view']); }); it('should use baseUrl when matching absolute routes', async () => { router = new Router(outlet, { baseUrl: '/foo/' }); await router.setRoutes([{ path: '/home', component: 'x-home-view' }], true); await router.render('/foo/home'); checkOutlet(['x-home-view']); }); it('should not throw when base path starts with double slash', async () => { baseElement.setAttribute('href', `${location.origin}//foo`); router = new Router(outlet); expect(router).to.have.property('baseUrl', `${location.origin}//`); await router.setRoutes([{ path: '(.*)', component: 'x-home-view' }], true); await router.render('//'); checkOutlet(['x-home-view']); }); }); describe('router.render(pathname)', () => { const add100msDelay = async () => await new Promise((resolve) => { setTimeout(resolve, 100); }); beforeEach(() => { router = new Router(outlet); }); it('should set a correct location to history when receiving a string path', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); await router.render('/', true); expect(window.location.pathname).to.be.equal('/'); expect(window.location.search).to.be.equal(''); expect(window.location.hash).to.be.equal(''); }); it('should throw if the router outlet is a not valid DOM Node (on finish)', async () => { const invalidOutlets = [undefined, null, 0, false, '', NaN]; for (const invalidOutlet of invalidOutlets) { const _router = new Router(outlet); await _router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const fulfilled: sinon.SinonSpy = sinon.spy(); const rejected: sinon.SinonSpy = sinon.spy(); const ready = _router.render('/').then(fulfilled).catch(rejected); // @ts-expect-error: testing invalid arguments _router.setOutlet(invalidOutlet); await ready; expect(fulfilled).to.not.have.been.called; expect(rejected).to.have.been.calledOnce; expect(rejected.args[0][0]).to.be.instanceof(TypeError); _router.unsubscribe(); } }); it('should return a promise that resolves to the router.location', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const result = router.render('/'); expect(result).to.be.a('promise'); const actual = await result; expect(actual).to.equal(router.location); }); it('should not set the String.search function to location when router.render(string)', async () => { await router.setRoutes([{ path: '/foo', component: 'x-home-view' }], true); await router.render('/foo'); expect(router.location.search).to.equal(''); expect(router.location.pathname).to.equal('/foo'); expect(router.location.hash).to.equal(''); }); it('should return a promise that resolves when the rendered content is appended to the DOM', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const promise = router.render('/'); expect(outlet.children).to.have.lengthOf(0); await promise; expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should return a promise that gets rejected on no-match', (done) => { const result = router.render('/path/not/defined'); result .then(() => { throw new Error('the promise should have been rejected'); }) .catch(() => done()); }); it('should create and append a route `component` into the router outlet', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); await router.render('/'); expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should rethrow DOMException if the route `component` is not a valid tag name', async () => { await router.setRoutes([{ path: '/', component: 'src/x-home-view' }], true); const fulfilled: sinon.SinonSpy = sinon.spy(); const rejected: sinon.SinonSpy = sinon.spy(); const ready = router.render('/').then(fulfilled).catch(rejected); await ready; expect(fulfilled).to.not.have.been.called; expect(rejected).to.have.been.calledOnce; expect(rejected.args[0][0]).to.be.instanceof(DOMException); }); it('should replace any pre-existing content of the router outlet', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/'); await router.render('/users'); expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-users-view/iu); }); it('should remove any pre-existing content of the router outlet on no-match', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); await router.render('/'); await router.render('/path/not/defined').catch(() => {}); expect(outlet.children).to.have.lengthOf(0); }); it('should ignore a successful result of a resolve pass if a new resolve pass is started before the first is completed', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/slow', action: add100msDelay, children: [{ path: '/users', component: 'x-users-view' }] }, { path: '/admin', component: 'x-admin-view' }, ], true, ); await Promise.all([ router.render('/slow/users'), // start the first resolve pass router.render('/admin'), // start the second resolve pass before the first is completed ]); expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-admin-view/iu); }); it('should ignore an error result of a resolve pass if a new resolve pass is started before the first is completed', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/slow', action: add100msDelay, children: [{ path: '/users', component: 'x-users-view' }] }, { path: '/admin', component: 'x-admin-view' }, ], true, ); await Promise.all([ router.render('/slow/non-existent').catch(() => {}), // start the first resolve pass router.render('/admin'), // start the second resolve pass before the first is completed ]); expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-admin-view/iu); }); it('should start a new resolve pass when route has "redirect" property', async () => { const from = '/people'; const pathname = '/users'; const result = { redirect: { pathname, from, params: {} } }; await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/people', redirect: '/users' }, { path: '/users', component: 'x-users-view' }, ], true, ); const spy = sinon.spy(router, 'resolve'); await router.render(from); expect(spy).to.be.calledTwice; const firstResult = await spy.firstCall.returnValue; expect(firstResult).to.have.property('result').that.deep.equals(result); const secondArg = spy.secondCall.args[0] as RouteContext; expect(secondArg.redirectFrom).to.equal(from); expect(secondArg.pathname).to.equal(pathname); }); it('should handle multiple redirects', async () => { await router.setRoutes( [ { path: '/a', redirect: '/b' }, { path: '/b', redirect: '/c' }, { path: '/c', component: 'x-home-view' }, ], true, ); await router.render('/a'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should fail on recursive redirects', async () => { await router.setRoutes( [ { path: '/a', redirect: '/b' }, { path: '/b', redirect: '/c' }, { path: '/c', redirect: '/a' }, ], true, ); const onError = sinon.stub<[unknown], undefined>(); await router.render('/a').catch(onError); expect(outlet.children).to.have.lengthOf(0); expect(onError).to.have.been.calledOnce; }); it('should render a component for the new route when redirecting', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/people', redirect: '/users' }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/people'); expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-users-view/iu); }); it('should use `window.replaceState()` when redirecting on initial render', async () => { const pushSpy = sinon.spy(window.history, 'pushState'); const replaceSpy = sinon.spy(window.history, 'replaceState'); await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/people', redirect: '/users' }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/people', true); expect(pushSpy).to.not.be.called; expect(replaceSpy).to.be.calledOnce; pushSpy.restore(); replaceSpy.restore(); }); it('should use `window.pushState()` when redirecting on next renders', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/people', redirect: '/users' }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/', true); const pushSpy = sinon.spy(window.history, 'pushState'); const replaceSpy = sinon.spy(window.history, 'replaceState'); await router.render('/people', true); expect(pushSpy).to.be.calledOnce; expect(replaceSpy).to.not.be.called; // If we render '/people' again right away, it redirects back to current URL. // The URL does not change in that case, and history is not updated. // Make non-redirecting render to update the URL. await router.render('/', true); expect(pushSpy).to.be.calledTwice; expect(replaceSpy).to.not.be.called; // Redirecting navigation again await router.render('/people', true); expect(pushSpy).to.be.calledThrice; expect(replaceSpy).to.not.be.called; pushSpy.restore(); replaceSpy.restore(); }); it('should set the `location.redirectFrom` property on the route component in case of redirect', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/people', redirect: '/users' }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/people'); expect(outlet.children[0]).to.have.nested.property('location.redirectFrom', '/people'); }); }); describe('router.ready', () => { beforeEach(() => { router = new Router(outlet); }); it('should be a promise', () => { expect(router).to.have.property('ready').that.is.a('promise'); }); it('(render in progress / ok) should get fulfilled after the render is completed', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); // eslint-disable-next-line no-void void router.render('/'); await router.ready.then((_location) => { expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); }); it('(render in progress / error) should get rejected with the current render error', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); // eslint-disable-next-line no-void void router.render('non-existent-path'); await router.ready .then(() => { throw new Error('the `ready` promise should have been rejected'); }) .catch((error: unknown) => { expect(outlet.children).to.have.lengthOf(0); expect(error).to.be.an('error'); expect(error).to.have.property('code', 404); expect(error) .to.have.property('message') .that.matches(/non-existent-path/u); }); }); it('(render completed / ok) should get fulfilled with the last render result', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); await router.render('/').then( async () => await router.ready.then(() => { expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }), ); }); it('(render completed / error) should get rejected with the last render error', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); await router.render('non-existent-path').catch( async () => await router.ready .then(() => { throw new Error('the `ready` promise should have been rejected'); }) .catch((error: unknown) => { expect(error).to.be.an('error'); expect(error).to.have.property('code', 404); expect(error) .to.have.property('message') .that.matches(/non-existent-path/u); }), ); }); it('(no renders yet) should get fulfilled before the first render completes', async () => { const sequence: string[] = []; const p1 = router.ready.then(() => { expect(outlet.children).to.have.lengthOf(0); sequence.push('no render yet'); }); await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); const p2 = router.render('/').then(() => { expect(outlet.children).to.have.lengthOf(1); sequence.push('first render done'); }); await Promise.all([p1, p2]).then(() => { expect(sequence[0]).to.equal('no render yet'); expect(sequence[1]).to.equal('first render done'); }); }); }); describe('router.location', () => { beforeEach(() => { router = new Router(outlet); }); it('should be a non-null object', () => { expect(router).to.have.property('location').that.is.an('object').and.is.not.null; }); it('should initially have non-null pathname, routes and params properties', () => { expect(router.location).to.have.property('pathname').that.is.a('string'); expect(router.location).to.have.property('routes').that.is.deep.equal([]); expect(router.location).to.have.property('params').that.is.deep.equal({}); }); it('should contain the baseUrl property', async () => { router.unsubscribe(); router = new Router(outlet, { baseUrl: '/foo/' }); await router.setRoutes([{ path: '', component: 'x-root' }], true); await router.render('/foo/'); expect(router.location.baseUrl).to.equal('/foo/'); }); it('should contain the pathname from the last completed render pass', async () => { await router.setRoutes( [ { path: '/', component: 'x-root' }, { path: '/a/b/c', component: 'x-a' }, ], true, ); await router.render('/'); await router.render('/a/b/c'); expect(router.location.pathname).to.equal('/a/b/c'); }); it('should contain the pathname from the last completed render pass (with params)', async () => { await router.setRoutes([{ path: '/a/b/:c', component: 'x-a' }], true); await router.render('/a/b/42'); expect(router.location.pathname).to.equal('/a/b/42'); }); it('should contain the final and the original pathnames from the last completed render pass (redirected)', async () => { await router.setRoutes( [ { path: '/a', redirect: '/c' }, { path: '/c', component: 'x-a' }, ], true, ); await router.render('/a'); expect(router.location.pathname).to.equal('/c'); expect(router.location.redirectFrom).to.equal('/a'); }); it('should contain the pathname after a failed render', async () => { await router.setRoutes([{ path: '/a', component: 'x-a' }], true); await router.render('/a'); await router.render('/non-existent-path').catch(() => {}); expect(router.location.pathname).to.equal('/non-existent-path'); }); it('should contain the routes chain from the last completed render pass (single route)', async () => { const route = { path: '/', component: 'x-home-view' }; await router.setRoutes(route); await router.ready; expect(router.location.routes).to.have.lengthOf(1); expect(router.location.routes[0]).to.equal(route); }); it('should contain the routes chain from the last completed render pass (multiple routes)', async () => { const routeC = { path: '/c', component: 'x-c' }; const routeB = { path: '/b', component: 'x-b', children: [routeC] }; const routeA = { path: '/a', component: 'x-a', children: [routeB] }; await router.setRoutes(routeA, true); await router.render('/a/b/c'); expect(router.location.routes).to.have.lengthOf(3); expect(router.location.routes[0]).to.equal(routeA); expect(router.location.routes[1]).to.equal(routeB); expect(router.location.routes[2]).to.equal(routeC); }); it('should contain an empty routes chain array after a failed render', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }], true); await router.render('/'); await router.render('/non-existent').catch(() => {}); expect(router.location.routes).to.deep.equal([]); }); it('should have a separate property for the last route of the routes chain', async () => { const routeC = { path: '/c', component: 'x-c' }; const routeB = { path: '/b', component: 'x-b', children: [routeC] }; const routeA = { path: '/a', component: 'x-a', children: [routeB] }; await router.setRoutes(routeA, true); await router.render('/a/b/c'); expect(router.location.route).to.equal(routeC); }); it('should have a `null` route property after a failed render', async () => { const routeC = { path: '/c', component: 'x-c' }; const routeB = { path: '/b', component: 'x-b', children: [routeC] }; const routeA = { path: '/a', component: 'x-a', children: [routeB] }; await router.setRoutes(routeA, true); await router.render('/a/b/c'); await router.render('/non-existent').catch(() => {}); expect(router.location.route).to.be.null; }); it('should contain the parameters from the last completed render pass', async () => { await router.setRoutes( [ { path: '/a/:b', component: 'x-a' }, { path: '/:a/:b', component: 'x-any' }, ], true, ); await router.render('/any/thing'); await router.render('/a/42'); expect(router.location.params).to.deep.equal({ b: '42' }); }); it('should contain the parameters from the last completed render pass (redirected)', async () => { await router.setRoutes( [ { path: '/a/:id', redirect: '/b/:id' }, { path: '/b/:id', component: 'x-b' }, ], true, ); await router.render('/a/42'); expect(router.location.params).to.deep.equal({ id: '42' }); }); it('should contain an empty parameters set after a failed render', async () => { await router.setRoutes([{ path: '/a/:id', component: 'x-a' }], true); await router.render('/a/42'); await router.render('/non-existent-path').catch(() => {}); expect(router.location.params).to.deep.equal({}); }); it('should update on router before component is connected', async () => { customElements.define( 'x-connected-location-test', class extends HTMLElement { connectedCallback() { expect((this as WebComponentInterface).location?.getUrl()).to.equal('/x-connected-location-test'); expect(router.location.getUrl()).to.equal('/x-connected-location-test'); } }, ); await router.setRoutes([{ path: '/x-connected-location-test', component: 'x-connected-location-test' }], true); await router.render('/x-connected-location-test'); }); describe('getUrl() method', () => { it('should exist', () => { expect(router.location).to.have.property('getUrl').which.is.a('function'); }); it('should return current location url with empty arguments', async () => { await router.setRoutes([{ path: '/a/:id', component: 'x-a' }], true); await router.render('/a/42'); expect(router.location.getUrl()).to.equal('/a/42'); }); it('should substitute in route path when given parameters', async () => { await router.setRoutes([{ path: '/a/:id', component: 'x-a' }], true); await router.render('/a/42'); expect(router.location.getUrl({ id: 'foo' })).to.equal('/a/foo'); }); it('should ignore unknown parameters', async () => { await router.setRoutes([{ path: '/a/:id', component: 'x-a' }], true); await router.render('/a/42'); expect(router.location.getUrl({ foo: 'bar' })).to.equal('/a/42'); }); it('should prepend baseUrl', async () => { (router as { baseUrl: string }).baseUrl = '/base/'; await router.setRoutes( [ { path: '/a', component: 'x-a' }, { path: 'b', component: 'x-b' }, ], true, ); await router.render('/base/a'); expect(router.location.getUrl()).to.equal('/base/a'); await router.render('/base/b'); expect(router.location.getUrl()).to.equal('/base/b'); }); it('should work in onBeforeEnter lifecycle method', async () => { const callback = sinon.spy(() => { expect(() => { router.location.getUrl(); }).to.not.throw(); }); await router.setRoutes([ { path: '/', action: onBeforeEnterAction('x-foo', callback), }, ]); await router.ready; expect(callback).to.have.been.calledOnce; }); // cannot mock the call to `compile()` from the 'pathToRegexp' package xit('should invoke pathToRegexp', async () => { await router.setRoutes([{ path: '/a/:id', component: 'x-a' }], true); await router.render('/a/42'); // @ts-ignore pathToRegexp is not exposed on the Router namespace anymore const compile = sinon.spy(Router.pathToRegexp, 'compile'); try { router.location.getUrl({ id: 'foo' }); expect(compile).to.be.calledWith('/a/:id'); } finally { // @ts-ignore pathToRegexp is not exposed on the Router namespace anymore // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access Router.pathToRegexp.compile.restore(); } }); }); }); describe('first render', () => { const onVaadinRouterGo = sinon.stub(); before(() => { window.addEventListener('vaadin-router-go', onVaadinRouterGo); }); afterEach(() => { onVaadinRouterGo.resetHistory(); }); after(() => { window.removeEventListener('vaadin-router-go', onVaadinRouterGo); }); it('should preserve pathname, search and hash', async () => { window.history.replaceState(null, '', '/admin?a=b#hash'); router = new Router(outlet); // eslint-disable-next-line no-void void router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/admin', component: 'x-admin-view' }, ]); await router.ready; // There should be no 'go' event yet. // The first navigation is called in `setRoutes()` expect(onVaadinRouterGo).not.to.be.called; expect(window.location.pathname).to.equal('/admin'); expect(window.location.search).to.equal('?a=b'); expect(window.location.hash).to.equal('#hash'); expect(router.location.pathname).to.equal('/admin'); expect(router.location.search).to.equal('?a=b'); expect(router.location.searchParams.get('a')).to.equal('b'); expect(router.location.hash).to.equal('#hash'); }); it('should preserve pathname and search', async () => { window.history.replaceState(null, '', '/admin?a=b'); router = new Router(outlet); // eslint-disable-next-line no-void void router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/admin', component: 'x-admin-view' }, ]); await router.ready; expect(onVaadinRouterGo).not.to.be.called; expect(window.location.pathname).to.equal('/admin'); expect(window.location.search).to.equal('?a=b'); expect(window.location.hash).to.equal(''); }); it('should preserve pathname', async () => { window.history.replaceState(null, '', '/admin'); router = new Router(outlet); // eslint-disable-next-line no-void void router.setRoutes([ { path: '/', component: 'x-home-view' }, { path: '/admin', component: 'x-admin-view' }, ]); await router.ready; expect(onVaadinRouterGo).not.to.be.called; expect(window.location.pathname).to.equal('/admin'); expect(window.location.search).to.equal(''); expect(window.location.hash).to.equal(''); expect(router.location.pathname).to.equal('/admin'); expect(router.location.search).to.equal(''); expect(router.location.searchParams.values().next().done).to.be.true; expect(router.location.hash).to.equal(''); }); }); describe('navigation events', () => { beforeEach(async () => { router = new Router(outlet); // configure router and let it render '/' await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/admin', component: 'x-admin-view' }, ], true, ); }); it('should update the history state after navigation', async () => { window.dispatchEvent(new CustomEvent('vaadin-router-go', { detail: { pathname: '/admin' } })); await router.ready; expect(window.location.pathname).to.equal('/admin'); }); it('should trigger a popstate event after navigation', async () => { const onpopstate = sinon.stub(); window.addEventListener('popstate', onpopstate); window.dispatchEvent(new CustomEvent('vaadin-router-go', { detail: { pathname: '/admin' } })); await router.ready; window.removeEventListener('popstate', onpopstate); expect(onpopstate).to.have.been.calledOnce; }); it('should not trigger a popstate event after navigation if the pathname has not changed', async () => { const onpopstate = sinon.stub(); window.addEventListener('popstate', onpopstate); window.dispatchEvent(new CustomEvent('vaadin-router-go', { detail: { pathname: '/' } })); await router.ready; window.removeEventListener('popstate', onpopstate); expect(onpopstate).to.not.have.been.called; }); it('should fire navigate event only once per single pathname change', async () => { const navigateSpy = sinon.stub(); window.addEventListener('vaadin-router-go', navigateSpy); const event = new CustomEvent('vaadin-router-go', { detail: { pathname: '/admin' } }); window.dispatchEvent(event); await router.ready; expect(navigateSpy).to.be.calledOnce; }); it('should automatically subscribe to navigation events when created', async () => { window.dispatchEvent(new CustomEvent('vaadin-router-go', { detail: { pathname: '/' } })); await router.ready; expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should unsubscribe from navigation events after an `unsubscribe()` method call', async () => { window.dispatchEvent(new CustomEvent('vaadin-router-go', { detail: { pathname: '/' } })); await router.ready; router.unsubscribe(); window.dispatchEvent(new CustomEvent('vaadin-router-go', { detail: { pathname: '/admin' } })); await router.ready; expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should subscribe to navigation events after a `subscribe()` method call', async () => { router.unsubscribe(); router.subscribe(); window.dispatchEvent(new CustomEvent('vaadin-router-go', { detail: { pathname: '/' } })); await router.ready; expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should handle updates to the routes config as navigation triggers', async () => { await router.setRoutes([{ path: '/', component: 'x-home-view' }]); await router.ready; expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should use the POPSTATE navigation trigger by default', async () => { window.dispatchEvent(new PopStateEvent('popstate')); await router.ready; expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should use the CLICK navigation trigger by default', async () => { link.click(); await router.ready; expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-admin-view/iu); }); it('should respect search detail property of `vaadin-router-go` event', async () => { window.dispatchEvent( new CustomEvent('vaadin-router-go', { detail: { pathname: '/admin', search: '?search', }, }), ); await router.ready; expect(window.location.search).to.equal('?search'); }); it('should respect hash detail property of `vaadin-router-go` event', async () => { window.dispatchEvent( new CustomEvent('vaadin-router-go', { detail: { pathname: '/admin', hash: '#hash', }, }), ); await router.ready; expect(window.location.hash).to.equal('#hash'); }); describe('Router.go() static method for navigation', () => { afterEach(async () => { await router.ready; }); it('should be exposed', async () => { Router.go('/admin'); await router.ready; expect(outlet.children).to.have.lengthOf(1); expect(outlet.children[0].tagName).to.match(/x-admin-view/iu); }); it('should trigger a `vaadin-router-go` event on the `window`', () => { const spy = sinon.stub(); window.addEventListener('vaadin-router-go', spy); Router.go('/'); window.removeEventListener('vaadin-router-go', spy); expect(spy).to.have.been.calledOnce; expect(spy.args[0][0]).to.have.property('type', 'vaadin-router-go'); }); it('should pass the given pathname in the `detail.pathname` property of the triggered event when Router.go() is called', () => { const spy = sinon.stub(); window.addEventListener('vaadin-router-go', spy); // use a valid route Router.go('/admin'); window.removeEventListener('vaadin-router-go', spy); expect(spy).to.have.been.calledOnce; expect(spy.args[0][0]).to.have.nested.property('detail.pathname', '/admin'); }); it('should support url with search string', () => { const spy = sinon.stub(); window.addEventListener('vaadin-router-go', spy); Router.go('/admin?foo=bar'); window.removeEventListener('vaadin-router-go', spy); expect(spy.args[0][0]).to.have.nested.property('detail.pathname', '/admin'); expect(spy.args[0][0]).to.have.nested.property('detail.search', '?foo=bar'); expect(spy.args[0][0]).to.have.nested.property('detail.hash', ''); }); it('should support url with hash string', () => { const spy = sinon.stub(); window.addEventListener('vaadin-router-go', spy); // use a valid route Router.go('/admin#foo'); window.removeEventListener('vaadin-router-go', spy); expect(spy.args[0][0]).to.have.nested.property('detail.pathname', '/admin'); expect(spy.args[0][0]).to.have.nested.property('detail.search', ''); expect(spy.args[0][0]).to.have.nested.property('detail.hash', '#foo'); }); it('should support url with search and hash strings', () => { const spy = sinon.stub(); window.addEventListener('vaadin-router-go', spy); // use a valid route Router.go('/admin?foo=bar#baz'); window.removeEventListener('vaadin-router-go', spy); expect(spy.args[0][0]).to.have.nested.property('detail.pathname', '/admin'); expect(spy.args[0][0]).to.have.nested.property('detail.search', '?foo=bar'); expect(spy.args[0][0]).to.have.nested.property('detail.hash', '#baz'); }); it('should support object argument with pathname, optional search, and optional hash', () => { const spy = sinon.stub(); window.addEventListener('vaadin-router-go', spy); Router.go({ pathname: '/admin' }); expect(spy.args[0][0]).to.have.nested.property('detail.pathname', '/admin'); expect(spy.args[0][0]).to.have.nested.property('detail.search', undefined); expect(spy.args[0][0]).to.have.nested.property('detail.hash', undefined); spy.resetHistory(); Router.go({ pathname: '/admin', search: '?foo=bar' }); expect(spy.args[0][0]).to.have.nested.property('detail.pathname', '/admin'); expect(spy.args[0][0]).to.have.nested.property('detail.search', '?foo=bar'); expect(spy.args[0][0]).to.have.nested.property('detail.hash', undefined); spy.resetHistory(); Router.go({ pathname: '/admin', hash: '#baz' }); expect(spy.args[0][0]).to.have.nested.property('detail.pathname', '/admin'); expect(spy.args[0][0]).to.have.nested.property('detail.search', undefined); expect(spy.args[0][0]).to.have.nested.property('detail.hash', '#baz'); window.removeEventListener('vaadin-router-go', spy); }); it('should return false by default', () => { router.unsubscribe(); const navigated = Router.go('/a'); expect(navigated).to.equal(false); router.subscribe(); }); it('should return true if `vaadin-router-go` default is prevented', () => { router.unsubscribe(); const eventPreventDefault = (e: Event) => e.preventDefault(); window.addEventListener('vaadin-router-go', eventPreventDefault); const navigated = Router.go('/a'); expect(navigated).to.equal(true); window.removeEventListener('vaadin-router-go', eventPreventDefault); router.subscribe(); }); }); describe('default action', () => { it('should be prevented from router by default', () => { expect( !window.dispatchEvent( new CustomEvent('vaadin-router-go', { cancelable: true, // use a valid route detail: { pathname: '/admin' }, }), ), ).to.be.true; }); it('should not be prevented when no router is subscribed', () => { router.unsubscribe(); expect( !window.dispatchEvent( new CustomEvent('vaadin-router-go', { cancelable: true, detail: { pathname: '/a' }, }), ), ).to.be.false; router.subscribe(); }); it('should be prevented for pathnames matching baseUrl', () => { (router as { baseUrl: string }).baseUrl = '/app/'; expect( !window.dispatchEvent( new CustomEvent('vaadin-router-go', { cancelable: true, detail: { pathname: '/app/admin' }, }), ), ).to.be.true; }); it('should not be prevented for pathnames not matching baseUrl', () => { (router as { baseUrl: string }).baseUrl = '/app/'; expect( !window.dispatchEvent( new CustomEvent('vaadin-router-go', { cancelable: true, detail: { pathname: '/other-app/home' }, }), ), ).to.be.false; }); }); }); describe('route parameters', () => { beforeEach(() => { router = new Router(outlet); }); it('should bind named parameters to `location.params` property using string keys', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/:user', component: 'x-user-profile' }, ], true, ); // eslint-disable-next-line no-void void router.render('/foo'); await router.ready.then(() => { const elem = outlet.children[0] as WebComponentInterface; expect(elem.location).to.be.an('object'); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.user).to.equal('foo'); }); }); it('should bind unnamed parameters to `location.params` property using numeric indexes', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/(user[s]?)/:id', component: 'x-users-view' }, ], true, ); // eslint-disable-next-line no-void void router.render('/users/1'); await router.ready.then(() => { const elem = outlet.children[0] as WebComponentInterface; expect(elem.location).to.be.an('object'); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params[0]).to.equal('users'); }); }); it('should bind named custom parameters to `location.params` property using string keys', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/image-:size(\\d+)px', component: 'x-image-view' }, ], true, ); // eslint-disable-next-line no-void void router.render('/image-15px'); await router.ready.then(() => { const elem = outlet.children[0] as WebComponentInterface; expect(elem.location).to.be.an('object'); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.size).to.equal('15'); }); }); it('should bind named segments to the `location.params` property using string keys', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/kb/:path+/:id', component: 'x-knowledge-base' }, ], true, ); // eslint-disable-next-line no-void void router.render('/kb/folder/nested/1'); await router.ready.then(() => { const elem = outlet.children[0] as WebComponentInterface; expect(elem.location).to.be.an('object'); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.path).to.deep.equal(['folder', 'nested']); expect(elem.location?.params.id).to.equal('1'); }); }); it('should set the `location.pathname` property on the route component', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/admin', component: 'x-admin-view' }, { path: '(.*)', component: 'x-not-found-view' }, ], true, ); await router.render('/non-existent/path'); expect(outlet.children[0]).to.have.nested.property('location.pathname', '/non-existent/path'); }); it('should set the `location` properties on the route component', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/admin', component: 'x-admin-view' }, { path: '(.*)', component: 'x-not-found-view' }, ], true, ); await router.render({ pathname: '/non-existent/path', search: '?foo=bar', hash: '#baz', }); expect(outlet.children[0]).to.have.nested.property('location.pathname', '/non-existent/path'); expect(outlet.children[0]).to.have.nested.property('location.search', '?foo=bar'); expect(outlet.children[0]).to.have.nested.property('location.hash', '#baz'); }); it('should update the `location` properties on the route component', async () => { await router.setRoutes([{ path: '(.*)', component: 'x-not-found-view' }], true); await router.render({ pathname: '/non-existent/path', search: '?foo=bar', hash: '#baz', }); await router.render({ pathname: '/non-existent/path', search: '?foo=qux', hash: '#quz', }); expect(outlet.children[0]).to.have.nested.property('location.pathname', '/non-existent/path'); expect(outlet.children[0]).to.have.nested.property('location.search', '?foo=qux'); expect(outlet.children[0]).to.have.nested.property('location.hash', '#quz'); }); it('should update the `location` properties on the route component on router.render(pathname)', async () => { await router.setRoutes([{ path: '(.*)', component: 'x-not-found-view' }], true); await router.render({ pathname: '/non-existent/path', search: '?foo=bar', hash: '#baz', }); await router.render('/non-existent/path'); expect(outlet.children[0]).to.have.nested.property('location.pathname', '/non-existent/path'); expect(outlet.children[0]).to.have.nested.property('location.search', ''); expect(outlet.children[0]).to.have.nested.property('location.hash', ''); }); it('should keep route parameters when redirecting to different route', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/users/:id', redirect: '/user/:id' }, { path: '/user/:id', component: 'x-users-view' }, ], true, ); await router.render('/users/1'); const elem = outlet.children[0] as WebComponentInterface; expect(elem.tagName).to.match(/x-users-view/iu); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.id).to.equal('1'); }); it('should create new component instance for the same route with different parameters', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/users/:id', component: 'x-users-view' }, ], true, ); await router.render('/users/1'); const elemOne = outlet.children[0] as WebComponentInterface; expect(elemOne.tagName).to.match(/x-users-view/iu); expect(elemOne.location?.params).to.be.an('object'); expect(elemOne.location?.params.id).to.equal('1'); await router.render('/users/2'); const elemTwo = outlet.children[0] as WebComponentInterface; expect(elemTwo.tagName).to.match(/x-users-view/iu); expect(elemTwo).to.not.equal(elemOne); expect(elemTwo.location?.params).to.be.an('object'); expect(elemTwo.location?.params.id).to.equal('2'); }); }); describe('route object properties: order of execution', () => { beforeEach(() => { router = new Router(outlet); }); it('action should be called with correct parameters', async () => { const action = sinon.spy((_context: RouteContext, _commands: Commands) => undefined); await router.setRoutes([{ path: '/', component: 'x-home-view', action }], true); await router.render('/'); expect(action).to.have.been.calledOnce; expect(action.args[0].length).to.equal(2); let [[contextParam]] = action.args; expect(contextParam.pathname).to.equal('/'); expect(contextParam.search).to.equal(''); expect(contextParam.hash).to.equal(''); expect(contextParam.route.path).to.equal('/'); expect(contextParam.route.component).to.equal('x-home-view'); const [[, commandsParam]] = action.args; expect(commandsParam).to.not.have.property('undefined'); expect(commandsParam).to.have.property('redirect').which.is.a('function'); expect(commandsParam).to.have.property('component').which.is.a('function'); action.resetHistory(); await router.render({ pathname: '/' }); [[contextParam]] = action.args; expect(contextParam.pathname).to.equal('/'); expect(contextParam.search).to.equal(''); expect(contextParam.hash).to.equal(''); action.resetHistory(); await router.render({ pathname: '/', search: '?foo=bar', hash: '#baz' }); [[contextParam]] = action.args; expect(contextParam.pathname).to.equal('/'); expect(contextParam.search).to.equal('?foo=bar'); expect(contextParam.hash).to.equal('#baz'); }); it('action.this points to the route that defines action', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view', action(context) { expect(this.path).to.equal('/'); expect(this.component).to.equal('x-home-view'); expect(this).to.be.equal(context.route); }, }, ], true, ); await router.render('/'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('action without return should be executed before redirect and allow it to happen', async () => { let actionExecuted = false; await router.setRoutes( [ { path: '/test', redirect: '/home', action: () => { actionExecuted = true; }, }, { path: '/home', component: 'x-home-view' }, ], true, ); await router.render('/test'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); expect(actionExecuted).to.equal(true); }); it('action with return should be executed before redirect and stop it from happening', async () => { let actionExecuted = false; await router.setRoutes( [ { path: '/home', redirect: '/users', async action(context) { actionExecuted = true; return await context.next(); }, }, { path: '/home', component: 'x-home-view' }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/home'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); expect(actionExecuted).to.equal(true); }); it('action with HTMLElement return should prevent redirect', async () => { await router.setRoutes( [ { path: '/', // eslint-disable-next-line @typescript-eslint/unbound-method action(_context, commands) { return commands.component('x-main-layout'); }, redirect: '/users', }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/'); checkOutlet(['x-main-layout']); }); it('action with redirect return should prevent matching children', async () => { await router.setRoutes( [ { path: '/home', // eslint-disable-next-line @typescript-eslint/unbound-method action: (_context, { redirect }) => redirect('/users'), component: 'x-main-layout', children: () => [{ path: '/', component: 'x-home-view' }], }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/home'); checkOutlet(['x-users-view']); expect(outlet.firstElementChild).to.not.be.null; expect(outlet.firstElementChild?.firstElementChild).to.be.null; }); it('action with HTMLElement return should not prevent matching children', async () => { await router.setRoutes( [ { path: '/', // eslint-disable-next-line @typescript-eslint/unbound-method action: (_context, { component }) => component('x-main-layout'), children: () => [{ path: '/', component: 'x-home-view' }], }, ], true, ); await router.render('/'); checkOutlet(['x-main-layout', 'x-home-view']); }); it('action with non-resolving return should not prevent route redirect', async () => { const returnValues = [undefined, null, NaN, 0, false, '', 'thisIsAlsoNonResolving', {}, Object.create(null)]; for (const returnValue of returnValues) { const _router = new Router(outlet); const erroneousPath = '/error'; let actionExecuted = false; await _router.setRoutes( [ { path: erroneousPath, redirect: '/users', action: () => { actionExecuted = true; return returnValue; }, }, { path: '/users', component: 'x-users-view' }, ], true, ); await _router.render(erroneousPath); expect(actionExecuted).to.equal(true); checkOutlet(['x-users-view']); } }); it('should redirect if action returns a Promise with non-resolving value', async () => { const returnValues = [undefined, null, NaN, 0, false, '', 'thisIsAlsoNonResolving', {}, Object.create(null)]; for (const returnValue of returnValues) { const _router = new Router(outlet); const erroneousPath = '/error'; let actionExecuted = false; await _router.setRoutes( [ { path: erroneousPath, redirect: '/users', action: async () => { actionExecuted = true; return await Promise.resolve(returnValue); }, }, { path: '/users', component: 'x-users-view' }, ], true, ); await _router.render(erroneousPath); expect(actionExecuted).to.equal(true); checkOutlet(['x-users-view']); } }); it('action with return should be executed before component and stop it from loading', async () => { let actionExecuted = false; await router.setRoutes( [ { path: '/', component: 'x-home-view', action: async (context) => { actionExecuted = true; return await context.next(); }, }, { path: '/', component: 'x-users-view' }, ], true, ); await router.render('/'); expect(actionExecuted).to.equal(true); expect(outlet.children[0].tagName).to.match(/x-users-view/iu); }); it('action can redirect by using the context method', async () => { await router.setRoutes( [ { path: '/users/:id', action: (_context, _commands) => _commands.redirect('/user/:id') }, { path: '/user/:id', component: 'x-users-view' }, ], true, ); await router.render('/users/1'); const elem = outlet.children[0] as WebComponentInterface; expect(elem.tagName).to.match(/x-users-view/iu); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.id).to.equal('1'); }); it('action can render components by using the context method', async () => { await router.setRoutes( [{ path: '/users/:id', action: (_context, commands) => commands.component('x-users-view') }], true, ); await router.render('/users/1'); const elem = outlet.children[0] as WebComponentInterface; expect(elem.tagName).to.match(/x-users-view/iu); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.id).to.equal('1'); }); it('action can render components by returning HTMLElement directly', async () => { await router.setRoutes( [{ path: '/users/:id', action: (_context, _commands) => document.createElement('x-users-view') }], true, ); await router.render('/users/1'); const elem = outlet.children[0] as WebComponentInterface; expect(elem.tagName).to.match(/x-users-view/iu); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.id).to.equal('1'); }); it('action can render components by returning Promise to HTMLElement', async () => { await router.setRoutes( [ { path: '/users/:id', action: async (_context, _commands) => await Promise.resolve(document.createElement('x-users-view')), }, ], true, ); await router.render('/users/1'); const elem = outlet.children[0] as WebComponentInterface; expect(elem.tagName).to.match(/x-users-view/iu); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.id).to.equal('1'); }); it('redirect should be executed before component and stop it from loading', async () => { await router.setRoutes( [ { path: '/test', redirect: '/users', component: 'x-home-view' }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/test'); expect(outlet.children[0].tagName).to.match(/x-users-view/iu); }); it('action should be executed after children function', async () => { const children = sinon.stub().returns([]); let actionExecuted = false; await router.setRoutes( [ { path: '/test', children, component: 'x-test', action: () => { actionExecuted = true; expect(children).to.have.been.called; }, }, ], true, ); await router.render('/test'); expect(actionExecuted).to.be.true; }); it('redirect should be executed after children function', async () => { const children = sinon.stub().returns([]); await router.setRoutes( [ { path: '/home', children, redirect: '/users' }, { path: '/users', component: 'x-users-view' }, ], true, ); await router.render('/home/1'); expect(outlet.children[0].tagName).to.match(/x-users-view/iu); expect(children).to.have.been.called; }); it('should match routes with trailing slashes', async () => { await router.setRoutes( [ { path: '/', component: 'x-a' }, { path: '/child', children: [ { path: '/', component: 'x-b' }, { path: '/page/', component: 'x-c' }, ], }, ], true, ); await router.render('/'); checkOutlet(['x-a']); await router.render('/child/'); checkOutlet(['x-b']); await router.render('/child/page/'); checkOutlet(['x-c']); }); }); describe('route.action (function)', () => { beforeEach(() => { router = new Router(outlet); }); it('result element should remain when rendering the same route', async () => { const result = document.createElement('div'); await router.setRoutes([{ path: '/', action: () => result }], true); await router.render('/'); await router.render('/'); expect(outlet.children[0]).to.be.equal(result); }); it('commands.redirect() should work when invoked without the `this` context', async () => { await router.setRoutes([ { path: '/', component: 'x-home-view' }, // eslint-disable-next-line @typescript-eslint/unbound-method { path: '/a', action: (_context, { redirect }) => redirect('/') }, ]); await router.render('/a'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it("commands.redirect() should update redirect.from and the next action's context.redirectFrom", async () => { const from = '/a/b/c'; await router.setRoutes( [ { path: '/a', children: [ { path: '/b', children: [ { path: '/c', action: (_context, commands) => { const redirectObject = commands.redirect('/d'); expect(redirectObject.redirect.from).to.be.equal(from); return redirectObject; }, }, ], }, ], }, { path: '/d', component: 'x-home-view', action: (context) => { expect(context.redirectFrom).to.be.equal(from); }, }, ], true, ); await router.render(from); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it("commands.redirect() should not produce double slashes in redirect.from and the next action's context.redirectFrom", async () => { let from: string | undefined; await router.setRoutes( [ { path: '/', children: [ { path: '', children: [{ path: '/c', action: (_context, commands) => commands.redirect('/d') }] }, ], }, { path: '/d', component: 'x-home-view', action: (context) => { from = context.redirectFrom; }, }, ], true, ); await router.render('/c'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); expect(from).to.be.equal('/c'); }); it('should be able to return different components for consecutive calls', async () => { let anonymous = true; await router.setRoutes( [ { path: '/', component: 'x-home-view', action: (_context, commands) => { if (anonymous) { return commands.component('x-login-view'); } return undefined; }, }, ], true, ); await router.render('/'); expect(outlet.children[0].tagName).to.match(/x-login-view/iu); anonymous = false; await router.render('/'); expect(outlet.children[0].tagName).to.match(/x-home-view/iu); }); it('should reuse DOM instance even when route has completely different path', async () => { let sharedElementInstance: HTMLElement | null = null; let serverSideComponentId = 0; const action: Route['action'] = (context) => { sharedElementInstance ||= document.createElement('flow-root-outlet'); const elm: HTMLElement = sharedElementInstance; // clear content elm.innerHTML = ''; // Assumed this is an element return by server const content = document.createElement(`server-side-${context.pathname.substring(1)}`); content.textContent = context.pathname; // eslint-disable-next-line no-plusplus serverSideComponentId++; content.id = `flow${serverSideComponentId}`; elm.appendChild(content); // Return always the same instance of flow-root-outlet return elm; }; await router.setRoutes( [ { path: '/', children: [ { path: 'client-view', component: 'x-client-view', }, { path: '(.*)', action, }, ], }, ], true, ); await router.render('/client-view'); expect(outlet.children[0].tagName).to.match(/x-client-view/iu); await router.render('/server-view'); expect(outlet.children[0]).to.be.equal(sharedElementInstance); expect(outlet.children[0].children[0].tagName).to.match(/server-side-server-view/iu); expect(outlet.children[0].children[0].id).to.be.equal(`flow${serverSideComponentId}`); expect(outlet.children[0].children[0].textContent).to.be.equal('/server-view'); await router.render('/reviews-list'); expect(outlet.children[0]).to.be.equal(sharedElementInstance); expect(outlet.children[0].children[0].tagName).to.match(/server-side-reviews-list/iu); expect(outlet.children[0].children[0].id).to.be.equal(`flow${serverSideComponentId}`); expect(outlet.children[0].children[0].textContent).to.be.equal('/reviews-list'); }); it('should keep tree structure and content when re-visiting the same route', async () => { const view = document.createElement('span'); const action: Route['action'] = (ctx) => { view.textContent = ctx.pathname; return view; }; await router.setRoutes( [ { path: '/', component: 'main-layout', children: [ { path: '/categories', component: 'input', }, { path: '(.*)', action, }, ], }, ], true, ); await router.render('/foo'); expect(outlet.children[0].localName).to.be.equal('main-layout'); expect(outlet.children[0].children[0].localName).to.be.equal('span'); expect(outlet.children[0].children[0].textContent).to.be.equal('/foo'); // Double click on the same route await router.render('/categories'); expect(outlet.children[0].localName).to.be.equal('main-layout'); expect(outlet.children[0].children[0].localName).to.be.equal('input'); await router.render('/categories'); expect(outlet.children[0].localName).to.be.equal('main-layout'); expect(outlet.children[0].children[0].localName).to.be.equal('input'); await router.render('/foo'); expect(outlet.children[0].localName).to.be.equal('main-layout'); expect(outlet.children[0].children[0].localName).to.be.equal('span'); expect(outlet.children[0].children[0].textContent).to.be.equal('/foo'); }); }); describe('route.action (function) return the same element tag with different content', () => { beforeEach(() => { router = new Router(outlet); }); it('should keep the element instance in the DOM when reuse the same instance with different content', async () => { let sharedElementInstance: HTMLElement | null = null; let dynamicContent = 'First content'; const action: Route['action'] = (_context) => { sharedElementInstance ||= document.createElement('div'); const elm: HTMLElement = sharedElementInstance; // clear content elm.innerHTML = ''; // Add some new content to the element const content = document.createElement('span'); content.textContent = dynamicContent; elm.appendChild(content); // Return always the same instance of the element return elm; }; await router.setRoutes([{ path: '/', action }], true); await router.ready; await router.render('/'); expect(outlet.children[0]).to.be.equal(sharedElementInstance); expect(outlet.textContent).to.be.equal(dynamicContent); dynamicContent = 'Second content'; // It should not disappear on the secondtime await router.render('/'); expect(outlet.children[0]).to.be.equal(sharedElementInstance); expect(outlet.textContent).to.be.equal(dynamicContent); }); it('should show the new content correctly when return a different instance but same tag name', async () => { let dynamicContent = 'First content'; const action: Route['action'] = (_context) => { const elm = document.createElement('div'); // clear content elm.innerHTML = ''; // Add some new content to the element const content = document.createElement('span'); content.textContent = dynamicContent; elm.appendChild(content); // Return always the same instance of the element return elm; }; await router.setRoutes([{ path: '/', action }], true); await router.render('/'); expect(outlet.textContent).to.be.equal(dynamicContent); dynamicContent = 'Second content'; // It should not disappear on the secondtime await router.render('/'); expect(outlet.textContent).to.be.equal(dynamicContent); }); it('should change parent content when parent is different but child is the same instance', async () => { const textContent = 'Text content'; let sharedElementInstance: HTMLElement | null = null; const action: Route['action'] = (_context) => { sharedElementInstance ||= document.createElement('x-edit'); const elm: HTMLElement = sharedElementInstance; // clear content elm.innerHTML = ''; // Add some new content to the element const content = document.createElement('span'); content.textContent = textContent; elm.appendChild(content); // Return always the same instance of the element return elm; }; await router.setRoutes( [ { path: '/', component: 'x-home', }, { path: '/users/:name/', action: (context) => { const userEl = document.createElement('x-user'); const avatarEl = document.createElement('x-fancy-name'); avatarEl.textContent = String(context.params.name); userEl.appendChild(avatarEl); return userEl; }, children: [ { path: 'edit', action, }, { path: 'profile', component: 'x-profile', }, ], }, ], true, ); await router.render('/users/john/edit'); expect(outlet.children[0].tagName).to.match(/x-user/iu); expect(outlet.children[0].children[0].tagName).to.match(/x-fancy-name/iu); expect(outlet.children[0].children[0].textContent).to.match(/john/iu); expect(outlet.children[0].children[1].tagName).to.match(/x-edit/iu); expect(outlet.children[0].children[1].textContent).to.be.equal(textContent); await router.render('/users/cena/edit'); expect(outlet.children[0].tagName).to.match(/x-user/iu); expect(outlet.children[0].children[0].tagName).to.match(/x-fancy-name/iu); expect(outlet.children[0].children[0].textContent).to.match(/cena/iu); expect(outlet.children[0].children[1].tagName).to.match(/x-edit/iu); expect(outlet.children[0].children[1].textContent).to.be.equal(textContent); }); }); describe('route.children (function)', () => { beforeEach(() => { router = new Router(outlet); }); it('should be able to return a list of routes', async () => { const children = () => [{ path: '/:user', component: 'x-user-profile' }]; await router.setRoutes([{ path: '/users', children }], true); await router.render('/users/2'); const elem = outlet.children[0] as WebComponentInterface; expect(elem.tagName).to.match(/x-user-profile/iu); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.user).to.equal('2'); }); it('should be able to return a promise', async () => { const children = async () => await Promise.resolve([{ path: '/:user', component: 'x-user-profile' }]); await router.setRoutes([{ path: '/users', children }], true); await router.render('/users/2'); const elem = outlet.children[0] as WebComponentInterface; expect(elem.tagName).to.match(/x-user-profile/iu); expect(elem.location?.params).to.be.an('object'); expect(elem.location?.params.user).to.equal('2'); }); it('should be able to override the route `children` property instead of returning a value', async () => { const children = sinon.spy((_context: RouteChildrenContext) => { _context.route.children = [{ path: '/:user', component: 'x-user-profile' }]; }); await router.setRoutes([{ path: '/users', children }], true); await router.render('/users/2'); expect(outlet.children[0].tagName).to.match(/x-user-profile/iu); await router.render('/users/2'); expect(outlet.children[0].tagName).to.match(/x-user-profile/iu); expect(children).to.have.been.calledOnce; }); it('should be called every time when resolver needs the route children list', async () => { const children = sinon.spy(() => [{ path: '/:user', component: 'x-user-profile' }]); await router.setRoutes([{ path: '/users', children }], true); await router.render('/users/1'); expect(outlet.children[0].tagName).to.match(/x-user-profile/iu); await router.render('/users/1'); expect(outlet.children[0].tagName).to.match(/x-user-profile/iu); expect(children).to.have.been.calledTwice; }); it('should throw if the return result is not an object or array', async () => { const children: ChildrenCallback = async () => await new Promise((resolve) => { // @ts-expect-error: Testing invalid return value resolve(null); }); await router.setRoutes([{ path: '/users', children }], true); await expectException(router.render('/users/1'), ['Incorrect "children" value']); }); it('should discard the previous return value and use the new one', async () => { let callCount = 0; // eslint-disable-next-line no-plusplus const children = () => (++callCount === 1 ? [{ path: '/:user', component: 'x-user-profile' }] : []); await router.setRoutes( [ { path: '/users', children }, { path: '(.*)', component: 'x-not-found-view' }, ], true, ); await router.render('/users/1'); expect(outlet.children[0].tagName).to.match(/x-user-profile/iu); await router.render('/users/1'); expect(outlet.children[0].tagName).to.match(/x-not-found-view/iu); }); it('should not be called when resolver does not need the route children list', async () => { const children = sinon.spy(); await router.setRoutes( [ { path: '/users', component: 'x-users-layout' }, { path: '/', children }, ], true, ); await router.ready.catch(() => {}); children.resetHistory(); await router.render('/users'); expect(outlet.children[0].tagName).to.match(/x-users-layout/iu); expect(children).to.not.have.been.called; }); it('should be called with the resolver context as the only argument', async () => { const children = sinon.spy((_context: RouteChildrenContext) => ({ component: 'x-home-view', path: '1', })); await router.setRoutes([{ path: '/users', children }], true); await router.render('/users/1'); expect(children).to.have.been.calledOnce; expect(children.args[0].length).to.equal(1); const [[context]] = children.args; expect(context.pathname).to.equal('/users/1'); expect(context.route.path).to.equal('/users'); expect(context).to.not.have.property('next'); }); it('should be called on the route object (as `this`)', async () => { const children = sinon.stub(); const route = { path: '/users', children }; await router.setRoutes([route], true); await router.render('/users/1').catch(() => {}); expect(children).to.have.been.calledOn(route); }); it('should cause resolver to throw if the returned routes are invalid', async () => { const incorrectRoutes = [ {}, true, { redirect: { pathname: '/' } }, () => false, new Promise((resolve) => { resolve(222); }), 2, 'whatever', { component: 'i-have-no-path-property' }, ]; for (const incorrectRoute of incorrectRoutes) { let exceptionThrown = false; try { await router.setRoutes( { path: '/a', // @ts-expect-error: Testing invalid return value children: async () => await incorrectRoute, }, true, ); await router.render('/a'); } catch { exceptionThrown = true; } expect( exceptionThrown, `No exception thrown for 'children' function incorrect return value '${String(incorrectRoute)}'`, ).to.be.true; } }); it('if the return value is a tree of nested routes, they should get resolved correctly', async () => { await router.setRoutes( [ { path: '/', component: 'x-root', children: [ { path: '/a', children: () => ({ path: '/b', children: async () => await Promise.resolve({ path: '/c', component: 'x-c', children: [ { path: '/d', component: 'x-d', }, ], }), }), }, ], }, ], true, ); await router.render('/a/b/c/d'); checkOutlet(['x-root', 'x-c', 'x-d']); }); it('if the return value is route with a `redirect`, it should get resolved correctly', async () => { await router.setRoutes( { path: '/a', children: () => [ { path: '/b', redirect: '/a/c', component: 'x-b' }, { path: '/c', component: 'x-c' }, ], }, true, ); await router.render('/a/b'); expect(outlet.children[0].tagName).to.match(/x-c/iu); }); }); describe('animated transitions', () => { let observer: MutationObserver; let data: MutationRecord[] = []; beforeEach(() => { router = new Router(outlet); observer = new window.MutationObserver((records) => { data = data.concat(records); }); }); afterEach(() => { observer.disconnect(); }); it('should set and then remove the CSS classes, if `animate` is set to true', async () => { await router.setRoutes( [ { path: '/', component: 'x-home-view' }, { path: '/animate', component: 'x-animate-view', animate: true }, ], true, ); await router.render('/'); observer.observe(outlet, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['class'], }); await router.render('/animate'); // FIXME(web-padawan): force IE11 to pick up mutations if (navigator.userAgent.includes('Trident') && data.length !== 4) { data = observer.takeRecords(); } expect(data.length).to.equal(4); expect((data[0].target as HTMLElement).tagName).to.match(/x-home-view/iu); expect((data[1].target as HTMLElement).tagName).to.match(/x-home-view/iu); expect(data[1].oldValue).to.equal('leaving'); expect((data[2].target as HTMLElement).tagName).to.match(/x-animate-view/iu); expect((data[3].target as HTMLElement).tagName).to.match(/x-animate-view/iu); expect(data[3].oldValue).to.equal('entering'); }); }); describe('window.Vaadin.registrations', () => { it('should contain a single record for the Vaadin Router usage', () => { // @ts-ignore Vaadin runtime object // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access const registrations = window.Vaadin.registrations.filter((reg) => reg.is === '@vaadin/router'); expect(registrations).to.have.lengthOf(1); // eslint-disable-next-line @typescript-eslint/unbound-method expect((registrations as readonly unknown[])[0]).to.have.property('version').that.is.a.string; }); }); }); }); ================================================ FILE: test/router/test-utils.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-expressions, no-unused-expressions */ /* eslint-enable chai-friendly/no-unused-expressions */ import { expect } from '@esm-bundle/chai'; import type { Commands, RouteContext, Router, WebComponentInterface, Route } from '../../src/index.js'; export async function waitForNavigation(): Promise { return await new Promise((resolve) => { window.addEventListener('popstate', () => resolve(), { once: true }); }); } export function cleanup(element: Element): void { element.innerHTML = ''; } export function verifyActiveRoutes(router: Router, expectedSegments: string[]): void { // @ts-expect-error: __previousContext is a private property expect(router.__previousContext?.chain?.map((item) => item.route.path)).to.deep.equal(expectedSegments); } function createWebComponentAction(method: T) { return ( componentName: string, callback: WebComponentInterface[T], name: string = 'unknown', ) => function lifecycleCallback( this: Route, _context: RouteContext, commands: Commands, ): WebComponentInterface { const component = commands.component(componentName) as WebComponentInterface; component.name = name; component[method] = callback; return component; }; } export const onBeforeLeaveAction = createWebComponentAction('onBeforeLeave'); export const onBeforeEnterAction = createWebComponentAction('onBeforeEnter'); export const onAfterEnterAction = createWebComponentAction('onAfterEnter'); export const onAfterLeaveAction = createWebComponentAction('onAfterLeave'); export function checkOutletContents( root: T | undefined, valueGetter: keyof T, expectedValues: readonly string[], ): void { let currentElementToCheck = root; for (const expectedValue of expectedValues) { expect(currentElementToCheck, `Failed to find a child '${expectedValue}'`).to.exist; expect(currentElementToCheck![valueGetter]).to.match(new RegExp(expectedValue, 'ui')); expect( currentElementToCheck!.children.length, `Expect each outlet element to have no more than 1 child`, ).to.be.below(2); currentElementToCheck = currentElementToCheck!.children[0] as T | undefined; } expect( currentElementToCheck, `Got '${String(expectedValues)}' values to check but got at least one more child in outlet: '${String( currentElementToCheck ? currentElementToCheck[valueGetter] : undefined, )}'`, ).to.be.an('undefined'); } ================================================ FILE: test/router/url-for.spec.ts ================================================ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; import { Router } from '../../src/router.js'; import '../setup.js'; import { cleanup } from './test-utils.js'; describe('urlFor', () => { let outlet: HTMLElement; let router: Router; before(() => { outlet = document.createElement('div'); document.body.append(outlet); history.pushState(null, '', '/'); }); after(() => { outlet.remove(); history.back(); }); beforeEach(() => { cleanup(outlet); history.replaceState(null, '', '/'); // create a new router instance router = new Router(outlet); }); afterEach(() => { router.unsubscribe(); }); describe('urlForName method', () => { beforeEach( async () => await router.setRoutes( [ { name: 'app', path: '/app/(.*)?', component: 'x-app', children: [ { name: 'home', path: '/home', component: 'x-home-view' }, { name: 'user', path: '/users/:userId', component: 'x-user-view' }, { name: 'user-profile', path: '/users/:userId/(.*)', component: 'x-user-profile' }, { path: '/users', component: 'x-user-list' }, ], }, ], true, ), ); it('should exist', () => { expect(router).to.have.property('urlForName').which.is.a('function'); }); it('should accept first-level route name', () => { expect(router.urlForName('app')).to.equal('/app'); }); it('should accept child route name', () => { expect(router.urlForName('home')).to.equal('/app/home'); }); it('should fallback to component name', () => { expect(router.urlForName('x-user-list')).to.equal('/app/users'); }); it('should drop extra path delimeters', async () => { await router.setRoutes([ { name: 'root', path: '/', component: 'x-root', children: [ { name: 'home', path: '/home/', component: 'x-home', children: [{ name: 'dashboard', path: '/dashboard/', component: 'x-dashboard' }], }, ], }, ]); expect(router.urlForName('dashboard')).to.equal('/home/dashboard/'); }); it('should not throw for children function', async () => { await router.setRoutes([{ name: 'root', path: '/', component: 'x-root', children: () => [] }]); expect(() => { expect(router.urlForName('root')).to.equal('/'); }).to.not.throw(); }); it('should allow setting new routes with old names', async () => { // Warm up the cache expect(router.urlForName('app')).to.equal('/app'); expect(router.urlForName('home')).to.equal('/app/home'); expect(router.urlForName('x-user-list')).to.equal('/app/users'); await router.setRoutes( [ { name: 'app', path: '/new-app', component: 'x-app', children: [ { name: 'home', path: '/new-home', component: 'x-home-view' }, { path: '/new-users', component: 'x-user-list' }, ], }, ], true, ); expect(router.urlForName('app')).to.equal('/new-app'); expect(router.urlForName('home')).to.equal('/new-app/new-home'); expect(router.urlForName('x-user-list')).to.equal('/new-app/new-users'); }); it('should support named parameters', () => { expect(router.urlForName('user', { userId: 42 })).to.equal('/app/users/42'); }); it('should support unnamed parameters', () => { expect(router.urlForName('app', { 0: 42 })).to.equal('/app/42'); }); it('should support named and unnamed parameters', () => { expect(router.urlForName('user-profile', { userId: 42, 0: null, 1: 'profile' })).to.equal( '/app/users/42/profile', ); }); it('should ignore unknown params', () => { expect(router.urlForName('user', { userId: 42, foo: 'bar' })).to.equal('/app/users/42'); }); it('should throw for not found name', () => { expect(() => router.urlForName('foo', {})).to.throw('Route "foo" not found'); }); it('should throw for route name duplicating route name', async () => { await router.setRoutes([ { name: 'foo', path: '/', component: 'x-foo-1' }, { name: 'foo', path: '/', component: 'x-foo-2' }, { name: 'x-unique', path: '/', component: 'x-unique' }, ]); expect(() => router.urlForName('foo')).to.throw('Duplicate'); expect(() => router.urlForName('x-unique')).to.not.throw(); }); it('should throw for component name duplicating route name', async () => { await router.setRoutes([ { name: 'x-foo', path: '/', component: 'x-foo-1' }, { path: '/', component: 'x-foo' }, { path: '/', component: 'x-unique' }, ]); expect(() => router.urlForName('x-foo')).to.throw('Duplicate'); expect(() => router.urlForName('x-unique')).to.not.throw(); }); it('should throw for route name duplicating component name', async () => { await router.setRoutes([ { path: '/', component: 'x-foo' }, { name: 'x-foo', path: '/', component: 'x-foo-2' }, { name: 'x-unique', path: '/', component: 'x-unique' }, ]); expect(() => router.urlForName('x-foo')).to.throw('Duplicate'); expect(() => router.urlForName('x-unique')).to.not.throw('not found'); }); it('should throw for component name duplicating component name', async () => { await router.setRoutes([ { path: '/', component: 'x-foo' }, { path: '/', component: 'x-foo' }, { path: '/', component: 'x-unique' }, ]); expect(() => router.urlForName('x-foo')).to.throw('Duplicate'); expect(() => router.urlForName('x-unique')).to.not.throw(); }); it('should not use component name when name is assigned', async () => { await router.setRoutes([{ name: 'foo', path: '/', component: 'x-foo' }]); expect(() => router.urlForName('x-foo')).to.throw(''); }); it('should prepend baseUrl', async () => { (router as { baseUrl: string }).baseUrl = '/base/'; await router.setRoutes([ { name: 'with-slash', path: '/foo', component: 'x-foo' }, { name: 'without-slash', path: 'bar', component: 'x-bar' }, ]); expect(router.urlForName('with-slash')).to.equal('/base/foo'); expect(router.urlForName('without-slash')).to.equal('/base/bar'); }); // cannot mock the call to `parse()` from the 'pathToRegexp' package xit('should use pathToRegexp', () => { // @ts-ignore not exposed anymore const parse = sinon.spy(Router.pathToRegexp, 'parse'); try { const name = 'user'; const path = '/app/(.*)?/users/:userId'; const parameters = { userId: 42, foo: 'bar' }; const result = router.urlForName(name, parameters); expect(parse).to.be.calledOnce; expect(parse).to.be.calledWithMatch(path); expect(result).to.equal('/app/users/42'); } finally { // @ts-ignore not exposed anymore // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access Router.pathToRegexp.parse.restore(); } }); }); describe('urlForPath method', () => { it('should exist', () => { expect(router).to.have.property('urlForPath').which.is.a('function'); }); it('should accept path', () => { expect(router.urlForPath('/users')).to.equal('/users'); }); it('should support named parameters', () => { expect(router.urlForPath('/users/:userId', { userId: 42 })).to.equal('/users/42'); }); it('should support unnamed parameters', () => { expect(router.urlForPath('/users/(.*)', { 0: 42 })).to.equal('/users/42'); }); it('should support named and unnamed parameters', () => { expect(router.urlForPath('/users/:userId/(.*)', { userId: 42, 0: 'profile' })).to.equal('/users/42/profile'); }); it('should ignore unknown params', () => { expect(router.urlForPath('/users/:userId', { userId: 42, foo: 'bar' })).to.equal('/users/42'); }); it('should prepend baseUrl', () => { (router as { baseUrl: string }).baseUrl = '/base/'; expect(router.urlForPath('foo')).to.equal('/base/foo'); expect(router.urlForPath('/bar')).to.equal('/base/bar'); }); // cannot mock the call to `compile()` from the 'pathToRegexp' package xit('should use pathToRegexp', () => { const compiledRegExp = sinon.stub().returns('/ok/url'); // @ts-ignore not exposed anymore // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access const compile = sinon.stub(Router.pathToRegexp, 'compile').returns(compiledRegExp); try { const path = '/users/:userId'; const parameters = { userId: 42, foo: 'bar' }; const result = router.urlForPath(path, parameters); expect(compile).to.be.calledOnce; expect(compile).to.be.calledWith(path); expect(compiledRegExp).to.be.calledOnce; expect(compiledRegExp).to.be.calledWithMatch(parameters); expect(compiledRegExp).to.have.returned('/ok/url'); expect(result).to.equal('/ok/url'); } finally { // @ts-ignore not exposed anymore // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access Router.pathToRegexp.compile.restore(); } }); }); }); ================================================ FILE: test/setup.ts ================================================ import sinon from 'sinon'; afterEach(() => { sinon.restore(); }); ================================================ FILE: test/transitions/animate.spec.ts ================================================ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; import animate from '../../src/transitions/animate.js'; import '../setup.js'; describe('animate', () => { let target: Element; function registerElement(element: `${string}-${string}`, template: string) { const tpl = document.createElement('template'); tpl.innerHTML = template; const ElementClass = class extends HTMLElement { connectedCallback() { const root = this.attachShadow({ mode: 'open' }); root.appendChild(document.importNode(tpl.content, true)); } }; customElements.define(element, ElementClass); } function attach(element: string) { target = document.createElement(element); document.body.appendChild(target); } afterEach(() => { document.body.removeChild(target); }); it('should wait for animation if CSS is applied to `animating` attribute', async () => { const element = 'x-fade-out'; const className = 'animating'; registerElement( element, ` `, ); attach(element); const spy = sinon.spy(); target.addEventListener('animationend', spy); await animate(target, className); expect(spy).to.be.calledOnce; }); }); ================================================ FILE: test/triggers/click.spec.ts ================================================ /* eslint-disable no-console */ /* eslint-disable no-bitwise */ // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button import { expect } from '@esm-bundle/chai'; import CLICK from '../../src/triggers/click.js'; import '../setup.js'; const TEMPLATE = `home in-app/link not a link (inside a link) in-app/link (target="_blank") in-app/link (download) ignore (router-ignore) http://example.com/in-app/link [same domain, different port] #in-page in-app/link?search in-app/link#hash`; const Button = { MAIN: 0, // usually left AUXILLARY: 1, // usually middle SECONDARY: 2, // usually right }; const Key = { ALT: 1 << 0, CONTROL: 1 << 1, META: 1 << 2, SHIFT: 1 << 3, }; describe('NavigationTriggers.CLICK', () => { function emulateClick(target: Element | null | undefined, button = Button.MAIN, keys = 0) { const ctrl = keys & Key.CONTROL; const alt = keys & Key.ALT; const shift = keys & Key.SHIFT; const meta = keys & Key.META; // TODO: replace with testing-library eventually const event = new MouseEvent('click', { // @ts-expect-error: old tests; not touching to avoid breaking them ctrlKey: ctrl, // @ts-expect-error: old tests; not touching to avoid breaking them altKey: alt, // @ts-expect-error: old tests; not touching to avoid breaking them shiftKey: shift, // @ts-expect-error: old tests; not touching to avoid breaking them metaKey: meta, button, bubbles: true, cancelable: true, composed: true, }); // eslint-disable-next-line @typescript-eslint/unbound-method const o = event.preventDefault; Object.defineProperty(event, 'preventDefault', { configurable: true, value() { console.log('PREVENTED'); console.trace(); return o.call(this); }, }); target?.dispatchEvent(event); } let preventNavigationDefault = true; const clicks: Array>> = []; function onWindowClick(event: Event) { clicks.push({ defaultPrevented: event.defaultPrevented }); event.preventDefault(); } const navigateEvents: Array>> = []; function onWindowNavigate(event: CustomEvent) { console.log('preventNavigationDefault', preventNavigationDefault); if (preventNavigationDefault) { event.preventDefault(); } navigateEvents.push({ type: event.type, cancelable: event.cancelable, detail: event.detail, }); } let outlet: HTMLElement; before(() => { const baseURLElement = document.createElement('base'); baseURLElement.href = location.origin; document.head.append(baseURLElement); outlet = document.createElement('div'); outlet.innerHTML = TEMPLATE; document.body.append(outlet); // Setup cross-origin link const origin = `${window.location.protocol}//${window.location.hostname}:${parseInt(window.location.port, 10) + 1}`; document.getElementById('cross-origin')?.setAttribute('href', `${origin}/in-app`); // Setup in-page hash link document.getElementById('in-page-hash-link')?.setAttribute('href', `${window.location.pathname}#in-page`); // Setup shadow roots const hosts = document.querySelectorAll('.shadow-host'); for (const host of hosts) { const template = host.querySelector('template')!; const root = host.attachShadow({ mode: 'open' }); root.appendChild(template.content.cloneNode(true)); } // Setup click events preventing document.getElementById('default-preventer')?.addEventListener('click', (event) => event.preventDefault()); window.addEventListener('click', onWindowClick); window.addEventListener('vaadin-router-go', onWindowNavigate); }); after(() => { outlet.remove(); window.removeEventListener('vaadin-router-go', onWindowNavigate); window.removeEventListener('click', onWindowClick); }); beforeEach(() => { // clear the array clicks.length = 0; navigateEvents.length = 0; }); it('should expose the NavigationTrigger API', () => { expect(CLICK).to.have.property('activate').that.is.a('function'); expect(CLICK).to.have.property('inactivate').that.is.a('function'); }); describe('activated', () => { before(() => { CLICK.activate(); }); after(() => { CLICK.inactivate(); }); it('should translate `click` events on links into `vaadin-router-go` events on window', () => { emulateClick(document.getElementById('in-app')); expect(navigateEvents).to.have.lengthOf(1); expect(navigateEvents[0]).to.have.property('type', 'vaadin-router-go'); }); it('should have cancelable `vaadin-router-go` events on window', () => { emulateClick(document.getElementById('in-app')); expect(navigateEvents).to.have.lengthOf(1); expect(navigateEvents[0]).to.have.property('cancelable', true); }); it('should prevent the `click` event default action if the `vaadin-router-go` event is prevented', () => { emulateClick(document.getElementById('in-app')); expect(clicks).to.have.lengthOf(1); expect(clicks[0]).to.have.property('defaultPrevented', true); }); // TODO: investigate and enable test back xit('should not prevent the `click` event default action if the `vaadin-router-go` event is not prevented', () => { preventNavigationDefault = false; // @ts-expect-error: DEBUGGER is not defined window.DEBUGGER = true; try { emulateClick(document.getElementById('in-app')); } finally { expect(clicks).to.have.lengthOf(1); expect(clicks[0]).to.have.property('defaultPrevented', false); preventNavigationDefault = true; } // @ts-expect-error: DEBUGGER is not defined window.DEBUGGER = false; }); it('should set the `detail.pathname` property of the `vaadin-router-go` event to the pathname of the clicked link', () => { emulateClick(document.getElementById('in-app')); expect(navigateEvents[0]).to.have.nested.property('detail.pathname', '/in-app/link'); }); it('should carry `detail.search` property on `vaadin-router-go` event', () => { emulateClick(document.getElementById('in-app')); expect(navigateEvents[0]).to.have.nested.property('detail.search', ''); emulateClick(document.getElementById('in-app-search')); expect(navigateEvents[1]).to.have.nested.property('detail.search', '?search'); }); it('should carry `detail.hash` property on `vaadin-router-go` event', () => { emulateClick(document.getElementById('in-app')); expect(navigateEvents[0]).to.have.nested.property('detail.hash', ''); emulateClick(document.getElementById('in-app-hash')); expect(navigateEvents[1]).to.have.nested.property('detail.hash', '#hash'); }); it('should translate `click` events inside links into `vaadin-router-go` events on window', () => { emulateClick(document.getElementById('text-in-a-link')); expect(navigateEvents).to.have.lengthOf(1); expect(navigateEvents[0]).to.have.property('type', 'vaadin-router-go'); expect(navigateEvents[0]).to.have.nested.property('detail.pathname', '/in-app/link'); expect(clicks).to.have.lengthOf(1); expect(clicks[0]).to.have.property('defaultPrevented', true); }); it('should translate `click` events on links inside Shadow DOM into `vaadin-router-go` events on window', () => { const shadowRoot = document.getElementById('shadow-host-with-a-link')?.shadowRoot; emulateClick(shadowRoot?.getElementById('in-app-in-a-shadow')); expect(navigateEvents).to.have.lengthOf(1); expect(navigateEvents[0]).to.have.property('type', 'vaadin-router-go'); expect(navigateEvents[0]).to.have.nested.property('detail.pathname', '/in-app/link'); expect(clicks).to.have.lengthOf(1); expect(clicks[0]).to.have.property('defaultPrevented', true); }); it('should translate `click` events inside shadow DOM inside links into `vaadin-router-go` events on window', () => { const shadowRoot = document.getElementById('shadow-host-in-a-link')?.shadowRoot; emulateClick(shadowRoot?.getElementById('text-in-a-shadow-in-a-link')); expect(navigateEvents).to.have.lengthOf(1); expect(navigateEvents[0]).to.have.property('type', 'vaadin-router-go'); expect(navigateEvents[0]).to.have.nested.property('detail.pathname', '/in-app/link'); expect(clicks).to.have.lengthOf(1); expect(clicks[0]).to.have.property('defaultPrevented', true); }); it('should work for navigation from /deep/pages/in/app to /', () => { const location = window.location.pathname; window.history.replaceState(null, document.title, '/deep/page/in/app'); emulateClick(document.getElementById('home')); window.history.replaceState(null, document.title, location); expect(navigateEvents).to.have.lengthOf(1); expect(navigateEvents[0]).to.have.property('type', 'vaadin-router-go'); expect(navigateEvents[0]).to.have.nested.property('detail.pathname', '/'); expect(clicks).to.have.lengthOf(1); expect(clicks[0]).to.have.property('defaultPrevented', true); }); it('should scroll to top on click event', () => { const div = document.createElement('div'); div.setAttribute('style', 'width:2000px; height:2000px;'); document.body.append(div); window.scrollTo(10, 10); expect(window.scrollX).to.within(9, 11); expect(window.scrollY).to.within(9, 11); emulateClick(document.getElementById('in-app')); expect(window.scrollX).to.within(0, 1); expect(window.scrollY).to.within(0, 1); document.body.removeChild(div); }); // TODO: investigate and enable test back xit('should not scroll to top on unhandled click event', () => { preventNavigationDefault = false; const div = document.createElement('div'); div.setAttribute('style', 'width:2000px; height:2000px;'); document.body.append(div); window.scrollTo(10, 10); expect(window.scrollX).to.be.within(9, 11); expect(window.scrollY).to.be.within(9, 11); emulateClick(document.getElementById('in-app')); expect(window.scrollX).to.be.within(9, 11); expect(window.scrollY).to.be.within(9, 11); document.body.removeChild(div); preventNavigationDefault = true; }); describe('irrelevant `click` events', () => { function expectClickIgnored() { expect(navigateEvents).to.have.lengthOf(0); expect(clicks).to.have.lengthOf(1); expect(clicks[0]).to.have.property('defaultPrevented', false); } it('should ignore `click` events with the secondary mouse button', () => { emulateClick(document.getElementById('in-app'), Button.SECONDARY); expectClickIgnored(); }); it('should ignore `click` events with the middle mouse button', () => { emulateClick(document.getElementById('in-app'), Button.AUXILLARY); expectClickIgnored(); }); it('should ignore `click` events if the SHIFT modifier key is also pressed', () => { emulateClick(document.getElementById('in-app'), Button.MAIN, Key.SHIFT); expectClickIgnored(); }); it('should ignore `click` events if the CONTROL modifier key is also pressed', () => { emulateClick(document.getElementById('in-app'), Button.MAIN, Key.CONTROL); expectClickIgnored(); }); it('should ignore `click` events if the ALT modifier key is also pressed', () => { emulateClick(document.getElementById('in-app'), Button.MAIN, Key.ALT); expectClickIgnored(); }); it('should ignore `click` events if the META modifier key is also pressed', () => { emulateClick(document.getElementById('in-app'), Button.MAIN, Key.META); expectClickIgnored(); }); it('should ignore `click` events on links with a non-default target', () => { emulateClick(document.getElementById('in-app-target-blank')); expectClickIgnored(); }); it('should ignore `click` events on links with a `download` attribute', () => { emulateClick(document.getElementById('in-app-download')); expectClickIgnored(); }); it('should ignore `click` events on page-local links (same pathname, different hash)', () => { emulateClick(document.getElementById('in-page-hash-link')); expectClickIgnored(); }); it('should ignore `click` events on external links', () => { emulateClick(document.getElementById('external')); expectClickIgnored(); }); it('should ignore `click` events on cross-origin links', () => { emulateClick(document.getElementById('cross-origin')); expectClickIgnored(); }); it('should ignore `click` events on non-link elements', () => { emulateClick(document.getElementById('not-a-link')); expectClickIgnored(); }); it('should ignore `click` events on links with a `router-ignore` attribute', () => { emulateClick(document.getElementById('ignore-link')); expectClickIgnored(); }); it('should ignore `click` events with prevented default action', () => { emulateClick(document.getElementById('in-app-prevented')); expect(navigateEvents).to.have.lengthOf(0); expect(clicks).to.have.lengthOf(1); expect(clicks[0]).to.have.property('defaultPrevented', true); }); }); }); describe('inactivated', () => { before(() => { CLICK.inactivate(); }); it('should not translate `click` events into `vaadin-router-go` when inactivated', () => { emulateClick(document.getElementById('in-app')); expect(navigateEvents).to.have.lengthOf(0); }); it('should not prevent the default action on the original `click` event', () => { emulateClick(document.getElementById('in-app')); expect(clicks).to.have.lengthOf(1); expect(clicks[0]).to.have.property('defaultPrevented', false); }); }); }); ================================================ FILE: test/triggers/popstate.spec.ts ================================================ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; import POPSTATE from '../../src/triggers/popstate.js'; describe('NavigationTriggers.POPSTATE', () => { before(() => { window.history.pushState(null, '', '/'); }); beforeEach(() => { window.history.replaceState(null, '', '/'); }); after(() => { window.history.back(); }); it('should expose the NavigationTrigger API', () => { expect(POPSTATE).to.have.property('activate').that.is.a('function'); expect(POPSTATE).to.have.property('inactivate').that.is.a('function'); }); it('should translate `popstate` events into `vaadin-router-go` when activated', () => { POPSTATE.inactivate(); const spy = sinon.spy(); window.addEventListener('vaadin-router-go', spy); POPSTATE.activate(); window.history.replaceState(null, '', '/test-url?search#hash'); window.dispatchEvent(new PopStateEvent('popstate')); window.removeEventListener('vaadin-router-go', spy); expect(spy).to.have.been.calledOnce; expect(spy.args[0][0]).to.have.property('type', 'vaadin-router-go'); expect(spy.args[0][0]).to.have.nested.property('detail.pathname'); expect(spy.args[0][0]).to.have.nested.property('detail.search'); expect(spy.args[0][0]).to.have.nested.property('detail.hash'); // FIXME: assert values also // expect(spy.args[0][0]).to.have.nested.property('detail.pathname', '/test-url'); // expect(spy.args[0][0]).to.have.nested.property('detail.search', '?search'); // expect(spy.args[0][0]).to.have.nested.property('detail.hash', '#hash'); sinon.restore(); }); it('should ignore `popstate` events with the `vaadin-router-ignore` state', () => { POPSTATE.inactivate(); const spy = sinon.spy(); window.addEventListener('vaadin-router-go', spy); POPSTATE.activate(); window.history.replaceState(null, '', '/test-url'); window.dispatchEvent(new PopStateEvent('popstate', { state: 'vaadin-router-ignore' })); window.removeEventListener('vaadin-router-go', spy); expect(spy).to.not.have.been.called; }); it('should not translate `popstate` events into `vaadin-router-go` when inactivated', () => { POPSTATE.activate(); POPSTATE.inactivate(); const spy = sinon.spy(); window.addEventListener('vaadin-router-go', spy); window.history.replaceState(null, '', '/test-url'); window.dispatchEvent(new PopStateEvent('popstate')); window.removeEventListener('vaadin-router-go', spy); expect(spy).to.not.have.been.called; }); }); ================================================ FILE: test/triggers/setNavigationTriggers.spec.ts ================================================ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; import { setNavigationTriggers } from '../../src/triggers/navigation.js'; import '../setup.js'; describe('setNavigationTriggers', () => { function createTriggerMock() { return { activate: sinon.spy(), inactivate: sinon.spy(), }; } it('should activate the given navigation trigger (if single)', () => { const trigger = createTriggerMock(); setNavigationTriggers([trigger]); expect(trigger.activate).to.have.been.calledOnce; expect(trigger.inactivate).to.not.have.been.called; }); it('should activate each given navigation trigger (if multiple)', () => { const trigger1 = createTriggerMock(); const trigger2 = createTriggerMock(); setNavigationTriggers([trigger1, trigger2]); expect(trigger1.activate).to.have.been.calledOnce; expect(trigger1.inactivate).to.not.have.been.called; expect(trigger2.activate).to.have.been.calledOnce; expect(trigger2.inactivate).to.not.have.been.called; }); it('should not crash if no triggers are given', () => { expect(() => setNavigationTriggers([])).to.not.throw; expect(() => setNavigationTriggers()).to.not.throw; }); it("should inactivate a previously given navigation trigger if it's not present in a repeat call", () => { const trigger1 = createTriggerMock(); const trigger2 = createTriggerMock(); setNavigationTriggers([trigger1]); setNavigationTriggers([trigger2]); expect(trigger1.inactivate).to.have.been.calledOnce; }); }); ================================================ FILE: test/typescript/compile_fixture.ts ================================================ // NOTE(platosha): This file is a test fixture for TypeScript declarations. // Only the compilation success is tested. We don’t emit any JS from the // code below and don't execute it. /* eslint-disable */ import { Router, type NavigationTrigger, type Route, type Commands, type RouteContext, type RouterLocation, type WebComponentInterface, type PreventAndRedirectCommands, type PreventCommands, type EmptyCommands, } from '../../src/index.js'; import { DEFAULT_TRIGGERS } from '../../src/triggers/navigation.js'; const outlet = document.body.firstChild as Element; function expectTypeOfValue(t: T): void { t; } type ActionFn = NonNullable; type RouteMeta = Readonly<{ title: string; }>; const routerWithMeta = new Router(); routerWithMeta.setRoutes([ { path: '', component: 'page-index', title: 'Index page', }, ]); // Instantiation styles new Router().unsubscribe(); new Router(outlet).unsubscribe(); new Router(outlet, {}).unsubscribe(); new Router(outlet, { baseUrl: '/' }).unsubscribe(); new Router(null).unsubscribe(); new Router(null, {}).unsubscribe(); new Router(null, { baseUrl: '/' }).unsubscribe(); new Router(undefined).unsubscribe(); new Router(undefined, {}).unsubscribe(); new Router(undefined, { baseUrl: '/' }).unsubscribe(); const router = new Router(outlet, { baseUrl: '/' }); // Static methods Router.setTriggers(); const { CLICK, POPSTATE } = DEFAULT_TRIGGERS; Router.setTriggers(CLICK); Router.setTriggers(POPSTATE); Router.setTriggers(CLICK, POPSTATE); const MyNavigationTrigger: NavigationTrigger = { activate() {}, inactivate() {}, }; Router.setTriggers(CLICK, POPSTATE, MyNavigationTrigger); Router.setTriggers({ activate() {}, inactivate() {} }); Router.setTriggers({ activate() {}, inactivate() {} }, { activate() {}, inactivate() {} }); // Basic properties expectTypeOfValue(router.baseUrl); expectTypeOfValue(router.location); expectTypeOfValue(router.location.baseUrl); expectTypeOfValue(router.location.params); expectTypeOfValue(router.location.pathname); expectTypeOfValue(router.location.route); expectTypeOfValue(router.location.searchParams); expectTypeOfValue(router.location.routes); expectTypeOfValue(router.location.getUrl()); expectTypeOfValue(router.location.getUrl({})); expectTypeOfValue(router.location.getUrl([])); expectTypeOfValue>(router.ready); // Basic methods router.render('/'); (): ReturnType extends Promise ? true : never => true; router.subscribe(); router.unsubscribe(); expectTypeOfValue(router.urlForName('foo')); expectTypeOfValue(router.urlForName('foo', null)); expectTypeOfValue(router.urlForName('foo', { bar: 'yes' })); expectTypeOfValue(router.urlForName('foo', ['yes'])); expectTypeOfValue(router.urlForPath('foo')); expectTypeOfValue(router.urlForPath('foo', null)); expectTypeOfValue(router.urlForPath('foo', { bar: 'yes' })); expectTypeOfValue(router.urlForPath('foo', ['yes'])); // Empty routes router.setRoutes([]); // Standalone route let r: Route = { path: '/standalone', component: 'x-standalone' }; r.redirect = '/x-standalone'; r = { ...r, action: () => {} }; router.setRoutes([r, { ...r, action: () => {} }]); // Action arguments r = { ...r, action: (context: RouteContext, commands: Commands) => { expectTypeOfValue(context.pathname); expectTypeOfValue(context.search); expectTypeOfValue(context.hash); expectTypeOfValue<{} | string[]>(context.params); expectTypeOfValue>(context.params.foo); expectTypeOfValue>(context.params[0]); expectTypeOfValue(context.route.path); if (context.pathname === '/next') { return context.next(); } else if (context.pathname === '/home') { return commands.component('x-home'); } else if (context.pathname === '/no-go') { return commands.prevent(); } return commands.redirect('/'); }, }; // Standalone route router.setRoutes([r]); // Single non-wrapped in Array route router.setRoutes({ path: '/', action() {} }); // Inline routes router.setRoutes([ { path: 'component', component: 'x-component' }, { path: 'redirect', redirect: '/redirect' }, { path: 'parent', name: 'with-children', children: [{ path: 'child', children: [] }] }, ]); // Various action return types router.setRoutes([ { path: 'action-nothing', action: () => {} }, { path: 'action-component', action: () => document.createElement('x-foo'), }, { path: 'action-commands-component', action: (_, commands: Commands) => commands.component('x-foo'), }, { path: 'action-commands-prevent', action: (_, commands: Commands) => commands.prevent(), }, { path: 'action-commands-redirect', action: (_, commands: Commands) => commands.redirect('/'), }, { path: 'action-next', action: async (context) => await context.next(), }, { path: 'async-action-nothing', action: async () => await Promise.resolve() }, { path: 'async-action-null', action: async () => await Promise.resolve(null) }, { path: 'async-action-component', action: async () => await Promise.resolve(document.createElement('x-foo')), }, { path: 'async-action-commands-component', action: async (_, commands: Commands) => await Promise.resolve(commands.component('x-foo')), }, { path: 'async-action-commands-prevent', action: async (_, commands: Commands) => await Promise.resolve(commands.prevent()), }, { path: 'async-action-commands-redirect', action: async (_, commands: Commands) => await Promise.resolve(commands.redirect('/')), }, { path: 'async-action-next', action: async (context) => await Promise.resolve(context.next()), }, ]); // setOutlet router.setOutlet(outlet); router.setOutlet(null); // getOutlet expectTypeOfValue(router.getOutlet()); // Location property class MyViewWithLocation extends HTMLElement { location: RouterLocation = router.location; connectedCallback() { this.localName; this.location.pathname; } } customElements.define('my-view-with-location', MyViewWithLocation); // Lifecycle class MyViewWithBeforeEnter extends HTMLElement implements WebComponentInterface { onBeforeEnter(location: RouterLocation, commands: PreventAndRedirectCommands, router: Router) { this.localName; location.baseUrl; router.baseUrl; commands.prevent(); if ('component' in commands) { throw new Error('unexpected'); } return commands.redirect('/'); } } customElements.define('my-view-with-before-enter', MyViewWithBeforeEnter); class MyViewWithBeforeLeave extends HTMLElement implements WebComponentInterface { onBeforeLeave(location: RouterLocation, commands: PreventCommands, router: Router) { this.localName; location.baseUrl; router.baseUrl; if ('component' in commands || 'redirect' in commands) { throw new Error('unexpected'); } return commands.prevent(); } } customElements.define('my-view-with-before-leave', MyViewWithBeforeLeave); class MyViewWithAfterEnter extends HTMLElement implements WebComponentInterface { onAfterEnter(location: RouterLocation, commands: EmptyCommands, router: Router) { this.localName; location.baseUrl; if ('component' in commands || 'redirect' in commands || 'prevent' in commands) { throw new Error('unexpected'); } router.baseUrl; } } customElements.define('my-view-with-after-enter', MyViewWithAfterEnter); class MyViewWithAfterLeave extends HTMLElement implements WebComponentInterface { onAfterLeave(location: RouterLocation, commands: EmptyCommands, router: Router) { this.localName; location.baseUrl; if ('component' in commands || 'redirect' in commands || 'prevent' in commands) { throw new Error('unexpected'); } router.baseUrl; } } customElements.define('my-view-with-after-leave', MyViewWithAfterLeave); ================================================ FILE: tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "declarationMap": true, "rootDir": "src", "outDir": "dist", "isolatedModules": true }, "include": [ "src" ] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "allowJs": true, "allowSyntheticDefaultImports": true, "declaration": true, "declarationMap": true, "target": "es2022", "module": "esnext", "moduleResolution": "bundler", "lib": ["es2022", "dom", "DOM.Iterable"], "allowArbitraryExtensions": true, "skipLibCheck": true, "strict": true, "strictBindCallApply": true, "sourceMap": true, "inlineSources": true, "verbatimModuleSyntax": true, "noImplicitOverride": true, "noImplicitAny": true, "useDefineForClassFields": true, "useUnknownInCatchVariables": true, "importHelpers": true }, "include": ["scripts/**/*", "src/**/*", "test/**/*", "vite.config.ts", "/*.cjs"] } ================================================ FILE: tsdoc.json ================================================ { "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", "tagDefinitions": [ { "tagName": "@interface", "syntaxKind": "modifier" }, { "tagName": "@event", "syntaxKind": "block" } ] } ================================================ FILE: typedoc.json ================================================ { "$schema": "https://typedoc.org/schema.json", "entryPoints": ["src/index.ts"], "excludePrivate": true, "out": ".docs/API", "sort": ["alphabetical"], "plugin": ["typedoc-plugin-missing-exports"], "tsconfig": "tsconfig.build.json", "useTsLinkResolution": false, "excludeInternal": true, "excludeExternals": true, "validation": { "notExported": true, "invalidLink": true, "notDocumented": true }, "navigationLinks": { "Live demo": "../index.html" } } ================================================ FILE: vite.config.ts ================================================ import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import type { PackageJson } from 'type-fest'; import { defineConfig } from 'vite'; import constructCss from './scripts/constructCss.js'; import loadRegisterJs from './scripts/loadRegisterJs'; const root = new URL('./', import.meta.url); const packageJson: PackageJson = await readFile(new URL('./package.json', root), 'utf8').then(JSON.parse); // https://vitejs.dev/config/ export default defineConfig({ resolve: { alias: { '/base': '', // support '/base' prefix for karma }, }, build: { target: 'esnext', rollupOptions: { output: { importAttributesKey: 'with', }, }, }, esbuild: { define: { __NAME__: `'${packageJson.name ?? '@hilla/unknown'}'`, __VERSION__: `'${packageJson.version ?? '0.0.0'}'`, }, supported: { decorators: false, 'import-attributes': true, 'top-level-await': true, }, }, optimizeDeps: { esbuildOptions: { supported: { decorators: false, 'import-attributes': true, 'top-level-await': true, }, target: 'esnext', }, }, plugins: [loadRegisterJs(), constructCss()], root: fileURLToPath(root), }); ================================================ FILE: wct.conf.cjs ================================================ var argv = require('yargs').argv; module.exports = { registerHooks: function(context) { if (argv.env === 'saucelabs') { // The list below is based on the browserslist config defined in package.json context.options.plugins.sauce.browsers = [ // last 2 Chrome major versions (desktop) 'Windows 10/chrome@latest', 'Windows 10/chrome@latest-1', // last 2 Android major versions (mobile Chrome) { deviceName: 'Android GoogleAPI Emulator', platformName: 'Android', platformVersion: '11.0', browserName: 'chrome', browserVersion: 'latest' }, { deviceName: 'Android GoogleAPI Emulator', platformName: 'Android', platformVersion: '10.0', browserName: 'chrome', browserVersion: 'latest-1' }, // last 2 Firefox major versions (desktop) 'Windows 10/firefox@latest', 'Windows 10/firefox@latest-1', // last Firefox ESR version (desktop) // SauceLabs doesn't have ESR versions so testing // the regular release of the same major version here 'Windows 10/firefox@78.0', // last 2 Edge major versions (desktop) 'Windows 10/microsoftedge@latest', 'Windows 10/microsoftedge@latest-1', // last 2 Safari major versions (desktop) 'macOS 11.00/safari@latest', 'macOS 10.15/safari@latest', // last 2 iOS major versions (mobile Safari) 'iOS Simulator/iphone@latest', 'iOS Simulator/iphone@latest-1', ]; } if (argv.profile === 'coverage') { context.options.suites = [ 'test/index.html' ]; context.options.plugins.local.browsers = ['chrome']; context.options.plugins.istanbul = { dir: './coverage', reporters: ['text-summary', 'lcov'], include: [ '**/dist/**/*.js', ], thresholds: { global: { statements: 80, branches: 80, functions: 80, lines: 80, } } }; } } };