import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Listen, Method, Prop } from '@stencil/core'; import type { AnimationBuilder, BackButtonEvent } from '../../interface'; import { debounce } from '../../utils/helpers'; import type { NavigationHookResult } from '../route/route-interface'; 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 type { RouteChain, RouterDirection, RouterEventDetail } from './utils/interface'; import { findChainForIDs, findChainForSegments, findRouteRedirect } from './utils/matching'; import { readRedirects, readRoutes } from './utils/parser'; import { chainToSegments, generatePath, parsePath, readSegments, writeSegments } from './utils/path'; @Component({ tag: 'ion-router', }) export class Router implements ComponentInterface { private previousPath: string | null = null; private busy = false; private state = 0; private lastState = 0; private waitPromise?: Promise; @Element() el!: HTMLElement; /** * The root path to use when matching URLs. By default, this is set to "/", but you can specify * an alternate prefix for all URL paths. */ @Prop() 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. */ @Prop() useHash = true; /** * Event emitted when the route is about to change */ @Event() ionRouteWillChange!: EventEmitter; /** * Emitted when the route had changed */ @Event() ionRouteDidChange!: EventEmitter; async componentWillLoad() { await waitUntilNavNode(); const canProceed = await this.runGuards(this.getSegments()); if (canProceed !== true) { if (typeof canProceed === 'object') { const { redirect } = canProceed; const path = parsePath(redirect); this.setSegments(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)); } @Listen('popstate', { target: 'window' }) protected async onPopState() { const direction = this.historyDirection(); let segments = this.getSegments(); 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); } @Listen('ionBackButton', { target: 'document' }) protected onBackButton(ev: BackButtonEvent) { ev.detail.register(0, (processNextHandler) => { this.back(); processNextHandler(); }); } /** @internal */ @Method() 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 path. * * @param path The path to navigate to. * @param direction The direction of the animation. Defaults to `"forward"`. */ @Method() async push(path: string, direction: RouterDirection = 'forward', animation?: AnimationBuilder) { if (path.startsWith('.')) { const currentPath = this.previousPath ?? '/'; // Convert currentPath to an URL by pre-pending a protocol and a host to resolve the relative path. const url = new URL(path, `https://host/${currentPath}`); path = url.pathname + url.search; } let parsedPath = parsePath(path); const canProceed = await this.runGuards(parsedPath.segments); if (canProceed !== true) { if (typeof canProceed === 'object') { parsedPath = parsePath(canProceed.redirect); } else { return false; } } this.setSegments(parsedPath.segments, direction, parsedPath.queryString); return this.writeNavStateRoot(parsedPath.segments, direction, animation); } /** Go back to previous page in the window.history. */ @Method() back(): Promise { window.history.back(); return Promise.resolve(this.waitPromise); } /** @internal */ @Method() async printDebug() { printRoutes(readRoutes(this.el)); printRedirects(readRedirects(this.el)); } /** @internal */ @Method() async navChanged(direction: RouterDirection): Promise { 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 = findChainForIDs(ids, routes); if (!chain) { console.warn( '[ion-router] no matching URL for ', ids.map((i) => i.id) ); return false; } const segments = chainToSegments(chain); if (!segments) { console.warn('[ion-router] router could not match path because some required param is missing'); return false; } this.setSegments(segments, direction); await this.safeWriteNavState(outlet, chain, ROUTER_INTENT_NONE, segments, 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. */ private onRedirectChanged() { const segments = this.getSegments(); if (segments && findRouteRedirect(segments, readRedirects(this.el))) { this.writeNavStateRoot(segments, 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. */ private onRoutesChanged() { return this.writeNavStateRoot(this.getSegments(), ROUTER_INTENT_NONE); } private historyDirection() { const win = window; if (win.history.state === null) { this.state++; win.history.replaceState(this.state, win.document.title, win.document.location?.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; } private async writeNavStateRoot( segments: string[] | null, direction: RouterDirection, animation?: AnimationBuilder ): Promise { if (!segments) { 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(segments, redirects); let redirectFrom: string[] | null = null; if (redirect) { const { segments: toSegments, queryString } = redirect.to!; this.setSegments(toSegments, direction, queryString); redirectFrom = redirect.from; segments = toSegments; } // lookup route chain const routes = readRoutes(this.el); const chain = findChainForSegments(segments, 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, segments, redirectFrom, 0, animation); } private async safeWriteNavState( node: HTMLElement | undefined, chain: RouteChain, direction: RouterDirection, segments: string[], redirectFrom: string[] | null, index = 0, animation?: AnimationBuilder ): Promise { const unlock = await this.lock(); let changed = false; try { changed = await this.writeNavState(node, chain, direction, segments, redirectFrom, index, animation); } catch (e) { console.error(e); } unlock(); return changed; } private async lock() { const p = this.waitPromise; let resolve!: () => void; 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. */ private async runGuards( to: string[] | null = this.getSegments(), from?: string[] | null ): Promise { if (from === undefined) { from = parsePath(this.previousPath).segments; } if (!to || !from) { return true; } const routes = readRoutes(this.el); const fromChain = findChainForSegments(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 = findChainForSegments(to, routes); const beforeEnterHook = toChain && toChain[toChain.length - 1].beforeEnter; return beforeEnterHook ? beforeEnterHook() : true; } private async writeNavState( node: HTMLElement | undefined, chain: RouteChain, direction: RouterDirection, segments: string[], redirectFrom: string[] | null, index = 0, animation?: AnimationBuilder ): Promise { 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(segments, 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; } private setSegments(segments: string[], direction: RouterDirection, queryString?: string) { this.state++; writeSegments(window.history, this.root, this.useHash, segments, direction, this.state, queryString); } private getSegments(): string[] | null { return readSegments(window.location, this.root, this.useHash); } private routeChangeEvent(toSegments: string[], redirectFromSegments: string[] | null): RouterEventDetail | null { const from = this.previousPath; const to = generatePath(toSegments); this.previousPath = to; if (to === from) { return null; } const redirectedFrom = redirectFromSegments ? generatePath(redirectFromSegments) : null; return { from, redirectedFrom, to, }; } }