UNPKG

@ionic/core

Version:

Base components for Ionic

505 lines (504 loc) • 16.1 kB
import { Component, Element, Event, Listen, Method, Prop } from '@stencil/core'; import { debounce } from '../../utils/helpers'; import { ROUTER_INTENT_BACK, ROUTER_INTENT_FORWARD, ROUTER_INTENT_NONE } from './utils/constants'; import { printRedirects, printRoutes } from './utils/debug'; import { readNavState, waitUntilNavNode, writeNavState } from './utils/dom'; import { findRouteRedirect, routerIDsToChain, routerPathToChain } from './utils/matching'; import { readRedirects, readRoutes } from './utils/parser'; import { chainToPath, generatePath, parsePath, readPath, writePath } from './utils/path'; export class Router { constructor() { this.previousPath = null; this.busy = false; this.state = 0; this.lastState = 0; /** * By default `ion-router` will match the routes at the root path ("/"). * That can be changed when * */ this.root = '/'; /** * The router can work in two "modes": * - With hash: `/index.html#/path/to/page` * - Without hash: `/path/to/page` * * Using one or another might depend in the requirements of your app and/or where it's deployed. * * Usually "hash-less" navigation works better for SEO and it's more user friendly too, but it might * requires additional server-side configuration in order to properly work. * * On the other side hash-navigation is much easier to deploy, it even works over the file protocol. * * By default, this property is `true`, change to `false` to allow hash-less URLs. */ this.useHash = true; } async componentWillLoad() { await waitUntilNavNode(); const canProceed = await this.runGuards(this.getPath()); if (canProceed !== true) { if (typeof canProceed === 'object') { const { redirect } = canProceed; const path = parsePath(redirect); this.setPath(path.segments, ROUTER_INTENT_NONE, path.queryString); await this.writeNavStateRoot(path.segments, ROUTER_INTENT_NONE); } } else { await this.onRoutesChanged(); } } componentDidLoad() { window.addEventListener('ionRouteRedirectChanged', debounce(this.onRedirectChanged.bind(this), 10)); window.addEventListener('ionRouteDataChanged', debounce(this.onRoutesChanged.bind(this), 100)); } async onPopState() { const direction = this.historyDirection(); let segments = this.getPath(); const canProceed = await this.runGuards(segments); if (canProceed !== true) { if (typeof canProceed === 'object') { segments = parsePath(canProceed.redirect).segments; } else { return false; } } return this.writeNavStateRoot(segments, direction); } onBackButton(ev) { ev.detail.register(0, processNextHandler => { this.back(); processNextHandler(); }); } /** @internal */ async canTransition() { const canProceed = await this.runGuards(); if (canProceed !== true) { if (typeof canProceed === 'object') { return canProceed.redirect; } else { return false; } } return true; } /** * Navigate to the specified URL. * * @param url The url to navigate to. * @param direction The direction of the animation. Defaults to `"forward"`. */ async push(url, direction = 'forward', animation) { if (url.startsWith('.')) { url = (new URL(url, window.location.href)).pathname; } let parsedPath = parsePath(url); const canProceed = await this.runGuards(parsedPath.segments); if (canProceed !== true) { if (typeof canProceed === 'object') { parsedPath = parsePath(canProceed.redirect); } else { return false; } } this.setPath(parsedPath.segments, direction, parsedPath.queryString); return this.writeNavStateRoot(parsedPath.segments, direction, animation); } /** * Go back to previous page in the window.history. */ back() { window.history.back(); return Promise.resolve(this.waitPromise); } /** @internal */ async printDebug() { printRoutes(readRoutes(this.el)); printRedirects(readRedirects(this.el)); } /** @internal */ async navChanged(direction) { if (this.busy) { console.warn('[ion-router] router is busy, navChanged was cancelled'); return false; } const { ids, outlet } = await readNavState(window.document.body); const routes = readRoutes(this.el); const chain = routerIDsToChain(ids, routes); if (!chain) { console.warn('[ion-router] no matching URL for ', ids.map(i => i.id)); return false; } const path = chainToPath(chain); if (!path) { console.warn('[ion-router] router could not match path because some required param is missing'); return false; } this.setPath(path, direction); await this.safeWriteNavState(outlet, chain, ROUTER_INTENT_NONE, path, null, ids.length); return true; } // This handler gets called when a `ion-route-redirect` component is added to the DOM or if the from or to property of such node changes. onRedirectChanged() { const path = this.getPath(); if (path && findRouteRedirect(path, readRedirects(this.el))) { this.writeNavStateRoot(path, ROUTER_INTENT_NONE); } } // This handler gets called when a `ion-route` component is added to the DOM or if the from or to property of such node changes. onRoutesChanged() { return this.writeNavStateRoot(this.getPath(), ROUTER_INTENT_NONE); } historyDirection() { var _a; const win = window; if (win.history.state === null) { this.state++; win.history.replaceState(this.state, win.document.title, (_a = win.document.location) === null || _a === void 0 ? void 0 : _a.href); } const state = win.history.state; const lastState = this.lastState; this.lastState = state; if (state > lastState || (state >= lastState && lastState > 0)) { return ROUTER_INTENT_FORWARD; } if (state < lastState) { return ROUTER_INTENT_BACK; } return ROUTER_INTENT_NONE; } async writeNavStateRoot(path, direction, animation) { if (!path) { console.error('[ion-router] URL is not part of the routing set'); return false; } // lookup redirect rule const redirects = readRedirects(this.el); const redirect = findRouteRedirect(path, redirects); let redirectFrom = null; if (redirect) { const { segments, queryString } = redirect.to; this.setPath(segments, direction, queryString); redirectFrom = redirect.from; path = segments; } // lookup route chain const routes = readRoutes(this.el); const chain = routerPathToChain(path, routes); if (!chain) { console.error('[ion-router] the path does not match any route'); return false; } // write DOM give return this.safeWriteNavState(document.body, chain, direction, path, redirectFrom, 0, animation); } async safeWriteNavState(node, chain, direction, path, redirectFrom, index = 0, animation) { const unlock = await this.lock(); let changed = false; try { changed = await this.writeNavState(node, chain, direction, path, redirectFrom, index, animation); } catch (e) { console.error(e); } unlock(); return changed; } async lock() { const p = this.waitPromise; let resolve; this.waitPromise = new Promise(r => resolve = r); if (p !== undefined) { await p; } return resolve; } // Executes the beforeLeave hook of the source route and the beforeEnter hook of the target route if they exist. // // When the beforeLeave hook does not return true (to allow navigating) then that value is returned early and the beforeEnter is executed. // Otherwise the beforeEnterHook hook of the target route is executed. async runGuards(to = this.getPath(), from) { if (from === undefined) { from = parsePath(this.previousPath).segments; } if (!to || !from) { return true; } const routes = readRoutes(this.el); const fromChain = routerPathToChain(from, routes); const beforeLeaveHook = fromChain && fromChain[fromChain.length - 1].beforeLeave; const canLeave = beforeLeaveHook ? await beforeLeaveHook() : true; if (canLeave === false || typeof canLeave === 'object') { return canLeave; } const toChain = routerPathToChain(to, routes); const beforeEnterHook = toChain && toChain[toChain.length - 1].beforeEnter; return beforeEnterHook ? beforeEnterHook() : true; } async writeNavState(node, chain, direction, path, redirectFrom, index = 0, animation) { if (this.busy) { console.warn('[ion-router] router is busy, transition was cancelled'); return false; } this.busy = true; // generate route event and emit will change const routeEvent = this.routeChangeEvent(path, redirectFrom); if (routeEvent) { this.ionRouteWillChange.emit(routeEvent); } const changed = await writeNavState(node, chain, direction, index, false, animation); this.busy = false; // emit did change if (routeEvent) { this.ionRouteDidChange.emit(routeEvent); } return changed; } setPath(path, direction, queryString) { this.state++; writePath(window.history, this.root, this.useHash, path, direction, this.state, queryString); } getPath() { return readPath(window.location, this.root, this.useHash); } routeChangeEvent(path, redirectFromPath) { const from = this.previousPath; const to = generatePath(path); this.previousPath = to; if (to === from) { return null; } const redirectedFrom = redirectFromPath ? generatePath(redirectFromPath) : null; return { from, redirectedFrom, to, }; } static get is() { return "ion-router"; } static get properties() { return { "root": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "By default `ion-router` will match the routes at the root path (\"/\").\nThat can be changed when" }, "attribute": "root", "reflect": false, "defaultValue": "'/'" }, "useHash": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The router can work in two \"modes\":\n- With hash: `/index.html#/path/to/page`\n- Without hash: `/path/to/page`\n\nUsing one or another might depend in the requirements of your app and/or where it's deployed.\n\nUsually \"hash-less\" navigation works better for SEO and it's more user friendly too, but it might\nrequires additional server-side configuration in order to properly work.\n\nOn the other side hash-navigation is much easier to deploy, it even works over the file protocol.\n\nBy default, this property is `true`, change to `false` to allow hash-less URLs." }, "attribute": "use-hash", "reflect": false, "defaultValue": "true" } }; } static get events() { return [{ "method": "ionRouteWillChange", "name": "ionRouteWillChange", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Event emitted when the route is about to change" }, "complexType": { "original": "RouterEventDetail", "resolved": "RouterEventDetail", "references": { "RouterEventDetail": { "location": "import", "path": "../../interface" } } } }, { "method": "ionRouteDidChange", "name": "ionRouteDidChange", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when the route had changed" }, "complexType": { "original": "RouterEventDetail", "resolved": "RouterEventDetail", "references": { "RouterEventDetail": { "location": "import", "path": "../../interface" } } } }]; } static get methods() { return { "canTransition": { "complexType": { "signature": "() => Promise<string | boolean>", "parameters": [], "references": { "Promise": { "location": "global" } }, "return": "Promise<string | boolean>" }, "docs": { "text": "", "tags": [{ "name": "internal", "text": undefined }] } }, "push": { "complexType": { "signature": "(url: string, direction?: RouterDirection, animation?: AnimationBuilder | undefined) => Promise<boolean>", "parameters": [{ "tags": [{ "text": "url The url to navigate to.", "name": "param" }], "text": "The url to navigate to." }, { "tags": [{ "text": "direction The direction of the animation. Defaults to `\"forward\"`.", "name": "param" }], "text": "The direction of the animation. Defaults to `\"forward\"`." }, { "tags": [], "text": "" }], "references": { "Promise": { "location": "global" }, "RouterDirection": { "location": "import", "path": "../../interface" }, "AnimationBuilder": { "location": "import", "path": "../../interface" } }, "return": "Promise<boolean>" }, "docs": { "text": "Navigate to the specified URL.", "tags": [{ "name": "param", "text": "url The url to navigate to." }, { "name": "param", "text": "direction The direction of the animation. Defaults to `\"forward\"`." }] } }, "back": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global" } }, "return": "Promise<void>" }, "docs": { "text": "Go back to previous page in the window.history.", "tags": [] } }, "printDebug": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global" } }, "return": "Promise<void>" }, "docs": { "text": "", "tags": [{ "name": "internal", "text": undefined }] } }, "navChanged": { "complexType": { "signature": "(direction: RouterDirection) => Promise<boolean>", "parameters": [{ "tags": [], "text": "" }], "references": { "Promise": { "location": "global" }, "RouterDirection": { "location": "import", "path": "../../interface" } }, "return": "Promise<boolean>" }, "docs": { "text": "", "tags": [{ "name": "internal", "text": undefined }] } } }; } static get elementRef() { return "el"; } static get listeners() { return [{ "name": "popstate", "method": "onPopState", "target": "window", "capture": false, "passive": false }, { "name": "ionBackButton", "method": "onBackButton", "target": "document", "capture": false, "passive": false }]; } }