From c8a27b7a1973b5431f578e951137eb9a5f733467 Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Tue, 6 Mar 2018 22:35:47 +0100 Subject: [PATCH] fix(ion-router): fixes routing algorithm --- .../src/components/router/router-utils.ts | 229 -------------- .../core/src/components/router/router.tsx | 31 +- .../components/router/test/common.spec.tsx | 47 +++ .../src/components/router/test/e2e.spec.tsx | 65 ++++ .../components/router/test/matching.spec.tsx | 279 ++++++++++++++++++ .../components/router/test/parser.spec.tsx | 66 +++++ .../src/components/router/test/path.spec.tsx | 55 ++++ .../router/test/router-utils.spec.tsx | 235 --------------- .../src/components/router/utils/common.ts | 51 ++++ .../core/src/components/router/utils/dom.ts | 48 +++ .../src/components/router/utils/interfaces.ts | 26 ++ .../src/components/router/utils/matching.ts | 71 +++++ .../src/components/router/utils/parser.ts | 49 +++ .../core/src/components/router/utils/path.ts | 62 ++++ packages/core/src/index.d.ts | 6 +- 15 files changed, 840 insertions(+), 480 deletions(-) create mode 100644 packages/core/src/components/router/test/common.spec.tsx create mode 100644 packages/core/src/components/router/test/e2e.spec.tsx create mode 100644 packages/core/src/components/router/test/matching.spec.tsx create mode 100644 packages/core/src/components/router/test/parser.spec.tsx create mode 100644 packages/core/src/components/router/test/path.spec.tsx delete mode 100644 packages/core/src/components/router/test/router-utils.spec.tsx create mode 100644 packages/core/src/components/router/utils/common.ts create mode 100644 packages/core/src/components/router/utils/dom.ts create mode 100644 packages/core/src/components/router/utils/interfaces.ts create mode 100644 packages/core/src/components/router/utils/matching.ts create mode 100644 packages/core/src/components/router/utils/parser.ts create mode 100644 packages/core/src/components/router/utils/path.ts diff --git a/packages/core/src/components/router/router-utils.ts b/packages/core/src/components/router/router-utils.ts index 08d32402a3..e69de29bb2 100644 --- a/packages/core/src/components/router/router-utils.ts +++ b/packages/core/src/components/router/router-utils.ts @@ -1,229 +0,0 @@ -export interface NavOutlet { - setRouteId(id: any, data: any, direction: number): Promise; - getRouteId(): string; - getContentElement(): HTMLElement | null; -} - -export type NavOutletElement = NavOutlet & HTMLStencilElement; - -export interface RouterEntry { - id: any; - path: string[]; - subroutes: RouterEntries; - props?: any; -} - -export type RouterEntries = RouterEntry[]; - -export class RouterSegments { - constructor( - private path: string[] - ) {} - - next(): string { - if (this.path.length > 0) { - return this.path.shift() as string; - } - return ''; - } -} - -export function writeNavState(root: HTMLElement, chain: RouterEntries, index: number, direction: number): Promise { - if (index >= chain.length) { - return Promise.resolve(); - } - const route = chain[index]; - const node = breadthFirstSearch(root); - if (!node) { - return Promise.resolve(); - } - return node.componentOnReady() - .then(() => node.setRouteId(route.id, route.props, direction)) - .then(changed => { - if (changed) { - direction = 0; - } - const nextEl = node.getContentElement(); - if (nextEl) { - return writeNavState(nextEl, chain, index + 1, direction); - } - return null; - }); -} - -export function readNavState(node: HTMLElement) { - const stack: string[] = []; - let pivot: NavOutlet|null; - while (true) { - pivot = breadthFirstSearch(node); - if (pivot) { - const cmp = pivot.getRouteId(); - if (cmp) { - node = pivot.getContentElement(); - stack.push(cmp.toLowerCase()); - } else { - break; - } - } else { - break; - } - } - return { - stack: stack, - pivot: pivot, - }; -} - -export function matchPath(stack: string[], routes: RouterEntries) { - const path: string[] = []; - for (const id of stack) { - const route = routes.find(r => r.id === id); - if (route) { - path.push(...route.path); - routes = route.subroutes; - } else { - break; - } - } - return { - path: path, - routes: routes, - }; -} - -export function matchRouteChain(path: string[], routes: RouterEntries): RouterEntries { - const chain = []; - const segments = new RouterSegments(path); - while (routes.length > 0) { - const route = matchRoute(segments, routes); - if (!route) { - break; - } - chain.push(route); - routes = route.subroutes; - } - return chain; -} - -export function matchRoute(segments: RouterSegments, routes: RouterEntries): RouterEntry | null { - if (!routes) { - return null; - } - let index = 0; - let selectedRoute: RouterEntry|null = null; - let ambiguous = false; - let segment: string; - let l: number; - - while (true) { - routes = routes.filter(r => r.path.length > index); - if (routes.length === 0) { - break; - } - segment = segments.next(); - routes = routes.filter(r => r.path[index] === segment); - l = routes.length; - if (l === 0) { - selectedRoute = null; - ambiguous = false; - } else { - selectedRoute = routes[0]; - ambiguous = l > 1; - } - index++; - } - if (ambiguous) { - throw new Error('ambiguious match'); - } - return selectedRoute; -} - -export function readRoutes(root: Element): RouterEntries { - return (Array.from(root.children) as HTMLIonRouteElement[]) - .filter(el => el.tagName === 'ION-ROUTE') - .map(el => ({ - path: parsePath(el.path), - id: el.component, - props: el.props, - subroutes: readRoutes(el) - })); -} - -export function generatePath(segments: string[]): string { - const path = segments - .filter(s => s.length > 0) - .join('/'); - - return '/' + path; -} - -export function parsePath(path: string): string[] { - if (path === null || path === undefined) { - return ['']; - } - const segments = path.split('/') - .map(s => s.trim()) - .filter(s => s.length > 0); - - if (segments.length === 0) { - return ['']; - } else { - return segments; - } -} - -const navs = ['ION-NAV', 'ION-TABS']; -export function breadthFirstSearch(root: HTMLElement): NavOutletElement | null { - if (!root) { - console.error('search root is null'); - return null; - } - // we do a Breadth-first search - // Breadth-first search (BFS) is an algorithm for traversing or searching tree - // or graph data structures.It starts at the tree root(or some arbitrary node of a graph, - // sometimes referred to as a 'search key'[1]) and explores the neighbor nodes - // first, before moving to the next level neighbours. - - const queue = [root]; - let node: HTMLElement | undefined; - while (node = queue.shift()) { - // visit node - if (navs.indexOf(node.tagName) >= 0) { - return node as NavOutletElement; - } - - // queue children - const children = node.children; - for (let i = 0; i < children.length; i++) { - queue.push(children[i] as NavOutletElement); - } - } - return null; -} - -export function writePath(history: History, base: string, usePath: boolean, path: string[], isPop: boolean, state: number) { - path = [base, ...path]; - let url = generatePath(path); - if (usePath) { - url = '#' + url; - } - state++; - if (isPop) { - history.back(); - history.replaceState(state, null, url); - } else { - history.pushState(state, null, url); - } - return state; -} - -export function readPath(loc: Location, base: string, useHash: boolean): string[] | null { - const path = useHash - ? loc.hash.substr(1) - : loc.pathname; - - if (path.startsWith(base)) { - return parsePath(path.slice(base.length)); - } - return null; -} diff --git a/packages/core/src/components/router/router.tsx b/packages/core/src/components/router/router.tsx index e6106e6bcc..b722227762 100644 --- a/packages/core/src/components/router/router.tsx +++ b/packages/core/src/components/router/router.tsx @@ -1,6 +1,10 @@ import { Component, Element, Listen, Prop } from '@stencil/core'; -import { RouterEntries, matchPath, matchRouteChain, readNavState, readPath, readRoutes, writeNavState, writePath } from './router-utils'; import { Config, DomController } from '../../index'; +import { flattenRouterTree, readRoutes } from './utils/parser'; +import { readNavState, writeNavState } from './utils/dom'; +import { chainToPath, readPath, writePath } from './utils/path'; +import { RouteChain } from './utils/interfaces'; +import { routerIDsToChain, routerPathToChain } from './utils/matching'; @Component({ @@ -8,7 +12,7 @@ import { Config, DomController } from '../../index'; }) export class Router { - private routes: RouterEntries; + private routes: RouteChain[]; private busy = false; private state = 0; @@ -22,7 +26,8 @@ export class Router { componentDidLoad() { // read config - this.routes = readRoutes(this.el); + const tree = readRoutes(this.el); + this.routes = flattenRouterTree(tree); // perform first write this.dom.raf(() => { @@ -49,16 +54,16 @@ export class Router { return; } console.debug('[IN] nav changed -> update URL'); - const { stack, pivot } = this.readNavState(); - const { path, routes } = matchPath(stack, this.routes); - if (pivot) { + const { ids, pivot } = this.readNavState(); + const { chain, matches } = routerIDsToChain(ids, this.routes); + if (chain.length > matches) { // readNavState() found a pivot that is not initialized console.debug('[IN] pivot uninitialized -> write partial nav state'); - this.writeNavState(pivot, [], routes, 0); + this.writeNavState(pivot, chain.slice(matches), 0); } const isPop = ev.detail.isPop === true; - this.writePath(path, isPop); + this.writePath(chain, isPop); } private writeNavStateRoot(): Promise { @@ -66,14 +71,13 @@ export class Router { const currentPath = this.readPath(); const direction = window.history.state >= this.state ? 1 : -1; if (currentPath) { - return this.writeNavState(node, currentPath, this.routes, direction); + const {chain} = routerPathToChain(currentPath, this.routes); + return this.writeNavState(node, chain, direction); } return Promise.resolve(); } - private writeNavState(node: any, path: string[], routes: RouterEntries, direction: number): Promise { - const chain = matchRouteChain(path, routes); - + private writeNavState(node: any, chain: RouteChain, direction: number): Promise { this.busy = true; return writeNavState(node, chain, 0, direction) .catch(err => console.error(err)) @@ -85,7 +89,8 @@ export class Router { return readNavState(root); } - private writePath(path: string[], isPop: boolean) { + private writePath(chain: RouteChain, isPop: boolean) { + const path = chainToPath(chain); this.state = writePath(window.history, this.base, this.useHash, path, isPop, this.state); } diff --git a/packages/core/src/components/router/test/common.spec.tsx b/packages/core/src/components/router/test/common.spec.tsx new file mode 100644 index 0000000000..57f9a0ccc4 --- /dev/null +++ b/packages/core/src/components/router/test/common.spec.tsx @@ -0,0 +1,47 @@ +import { RouterSegments, breadthFirstSearch } from '../utils/common'; + +describe('RouterSegments', () => { + it ('should initialize with empty array', () => { + const s = new RouterSegments([]); + expect(s.next()).toEqual(''); + expect(s.next()).toEqual(''); + expect(s.next()).toEqual(''); + expect(s.next()).toEqual(''); + expect(s.next()).toEqual(''); + }); + + it ('should initialize with array', () => { + const s = new RouterSegments(['', 'path', 'to', 'destination']); + expect(s.next()).toEqual(''); + expect(s.next()).toEqual('path'); + expect(s.next()).toEqual('to'); + expect(s.next()).toEqual('destination'); + expect(s.next()).toEqual(''); + expect(s.next()).toEqual(''); + }); +}); + +describe('breadthFirstSearch', () => { + it('should search in order', () => { + const n1 = { tagName: 'ION-TABS', children: [] as any }; + const n2 = { tagName: 'DIV', children: [n1] }; + const n3 = { tagName: 'ION-NAV', children: [n2] }; + const n4 = { tagName: 'ION-TABS', children: [] as any }; + const n5 = { tagName: 'DIV', children: [n4] }; + const n6 = { tagName: 'DIV', children: [n5, n3] }; + const n7 = { tagName: 'DIV', children: [] as any }; + const n8 = { tagName: 'DIV', children: [n6] }; + const n9 = { tagName: 'DIV', children: [n8, n7] }; + + expect(breadthFirstSearch(n9 as any)).toBe(n3); + expect(breadthFirstSearch(n8 as any)).toBe(n3); + expect(breadthFirstSearch(n7 as any)).toBe(null); + expect(breadthFirstSearch(n6 as any)).toBe(n3); + expect(breadthFirstSearch(n5 as any)).toBe(n4); + expect(breadthFirstSearch(n4 as any)).toBe(n4); + expect(breadthFirstSearch(n3 as any)).toBe(n3); + expect(breadthFirstSearch(n2 as any)).toBe(n1); + expect(breadthFirstSearch(n1 as any)).toBe(n1); + }); +}); + diff --git a/packages/core/src/components/router/test/e2e.spec.tsx b/packages/core/src/components/router/test/e2e.spec.tsx new file mode 100644 index 0000000000..81ef9d790a --- /dev/null +++ b/packages/core/src/components/router/test/e2e.spec.tsx @@ -0,0 +1,65 @@ +import { RouteChain } from '../utils/interfaces'; +import { routerIDsToChain, routerPathToChain } from '../utils/matching'; +import { mockRouteElement } from './parser.spec'; +import { chainToPath, generatePath, parsePath } from '../utils/path'; +import { flattenRouterTree, readRoutes } from '../utils/parser'; +import { mockElement } from '@stencil/core/dist/testing'; + +describe('ionic-conference-app', () => { + + it('should match conference-app routing', () => { + const root = conferenceAppRouting(); + const tree = readRoutes(root); + const routes = flattenRouterTree(tree); + + expect(getRouteIDs('/', routes)).toEqual(['page-tabs', 'tab-schedule', 'page-schedule']); + expect(getRouteIDs('/speaker', routes)).toEqual(['page-tabs', 'tab-speaker', 'page-speaker-list']); + expect(getRouteIDs('/map', routes)).toEqual(['page-tabs', 'page-map']); + expect(getRouteIDs('/about', routes)).toEqual(['page-tabs', 'page-about']); + expect(getRouteIDs('/tutorial', routes)).toEqual(['page-tutorial']); + + expect(getRoutePaths(['page-tabs', 'tab-schedule', 'page-schedule'], routes)).toEqual('/'); + expect(getRoutePaths(['page-tabs', 'tab-speaker', 'page-speaker-list'], routes)).toEqual('/speaker'); + expect(getRoutePaths(['page-tabs', 'page-map'], routes)).toEqual('/map'); + expect(getRoutePaths(['page-tabs', 'page-about'], routes)).toEqual('/about'); + expect(getRoutePaths(['page-tutorial'], routes)).toEqual('/tutorial'); + + }); +}); + + +function conferenceAppRouting() { + const p2 = mockRouteElement('/', 'tab-schedule'); + const p3 = mockRouteElement('/', 'page-schedule'); + p2.appendChild(p3); + + const p4 = mockRouteElement('/speaker', 'tab-speaker'); + const p5 = mockRouteElement('/', 'page-speaker-list'); + p4.appendChild(p5); + + const p6 = mockRouteElement('/map', 'page-map'); + const p7 = mockRouteElement('/about', 'page-about'); + + const p1 = mockRouteElement('/', 'page-tabs'); + p1.appendChild(p2); + p1.appendChild(p4); + p1.appendChild(p6); + p1.appendChild(p7); + + const p8 = mockRouteElement('/tutorial', 'page-tutorial'); + const container = mockElement('div'); + container.appendChild(p1); + container.appendChild(p8); + return container; +} + + + +function getRouteIDs(path: string, routes: RouteChain[]): string[] { + return routerPathToChain(parsePath(path), routes).chain.map(r => r.id); +} + +function getRoutePaths(ids: string[], routes: RouteChain[]): string { + return generatePath(chainToPath(routerIDsToChain(ids, routes).chain)); +} + diff --git a/packages/core/src/components/router/test/matching.spec.tsx b/packages/core/src/components/router/test/matching.spec.tsx new file mode 100644 index 0000000000..73efc5aca5 --- /dev/null +++ b/packages/core/src/components/router/test/matching.spec.tsx @@ -0,0 +1,279 @@ +import { RouteChain } from '../utils/interfaces'; +import { matchesIDs, matchesPath, routerPathToChain } from '../utils/matching'; +import { mockRouteElement } from './parser.spec'; +import { mockElement } from '@stencil/core/dist/testing'; + +const CHAIN_1: RouteChain = [ + { id: '2', path: ['to'], props: undefined }, + { id: '1', path: ['path'], props: undefined }, + { id: '3', path: ['segment'], props: undefined }, + { id: '4', path: [''], props: undefined }, +]; + +const CHAIN_2: RouteChain = [ + { id: '2', path: [''], props: undefined }, + { id: '1', path: [''], props: undefined }, + { id: '3', path: ['segment', 'to'], props: undefined }, + { id: '4', path: [''], props: undefined }, + { id: '5', path: ['hola'], props: undefined }, + { id: '6', path: [''], props: undefined }, + { id: '7', path: [''], props: undefined }, + { id: '8', path: ['adios', 'que', 'tal'], props: undefined }, +]; + +const CHAIN_3: RouteChain = [ + { id: '2', path: ['this', 'to'], props: undefined }, + { id: '1', path: ['path'], props: undefined }, + { id: '3', path: ['segment', 'to', 'element'], props: undefined }, + { id: '4', path: [''], props: undefined }, +]; + + + +describe('matchesIDs', () => { + it('should match simple set of ids', () => { + const chain: RouteChain = CHAIN_1; + expect(matchesIDs(['2'], chain)).toBe(1); + expect(matchesIDs(['2', '1'], chain)).toBe(2); + expect(matchesIDs(['2', '1', '3'], chain)).toBe(3); + expect(matchesIDs(['2', '1', '3', '4'], chain)).toBe(4); + expect(matchesIDs(['2', '1', '3', '4', '5'], chain)).toBe(4); + + expect(matchesIDs([], chain)).toBe(0); + expect(matchesIDs(['1'], chain)).toBe(0); + }); +}); + +describe('matchesPath', () => { + it('should match simple path', () => { + const chain: RouteChain = CHAIN_3; + expect(matchesPath(['this'], chain)).toBe(false); + expect(matchesPath(['this', 'to'], chain)).toBe(false); + expect(matchesPath(['this', 'to', 'path'], chain)).toBe(false); + expect(matchesPath(['this', 'to', 'path', 'segment'], chain)).toBe(false); + expect(matchesPath(['this', 'to', 'path', 'segment', 'to'], chain)).toBe(false); + expect(matchesPath(['this', 'to', 'path', 'segment', 'to', 'element'], chain)).toBe(true); + expect(matchesPath(['this', 'to', 'path', 'segment', 'to', 'element', 'more'], chain)).toBe(false); + + expect(matchesPath([], chain)).toBe(false); + expect(matchesPath([''], chain)).toBe(false); + expect(matchesPath(['path'], chain)).toBe(false); + }); + + it('should match simple default route', () => { + const chain: RouteChain = CHAIN_2; + expect(matchesPath([''], chain)).toBe(false); + expect(matchesPath(['segment'], chain)).toBe(false); + expect(matchesPath(['segment', 'to'], chain)).toBe(false); + expect(matchesPath(['segment', 'to', 'hola'], chain)).toBe(false); + expect(matchesPath(['segment', 'to', 'hola', 'adios'], chain)).toBe(false); + expect(matchesPath(['segment', 'to', 'hola', 'adios', 'que'], chain)).toBe(false); + expect(matchesPath(['segment', 'to', 'hola', 'adios', 'que', 'tal'], chain)).toBe(true); + + expect(matchesPath(['to'], chain)).toBe(false); + expect(matchesPath(['path', 'to'], chain)).toBe(false); + }); + + it('should match simple route 2', () => { + const chain: RouteChain = [{ id: '5', path: ['hola'], props: undefined }]; + expect(matchesPath([''], chain)).toBe(false); + expect(matchesPath(['hola'], chain)).toBe(true); + expect(matchesPath(['hola', 'hola'], chain)).toBe(true); + expect(matchesPath(['hola', 'adios'], chain)).toBe(true); + }); + + it('should match simple route 3', () => { + const chain: RouteChain = [{ id: '5', path: ['hola', 'adios'], props: undefined }]; + expect(matchesPath([''], chain)).toBe(false); + expect(matchesPath(['hola'], chain)).toBe(false); + expect(matchesPath(['hola', 'hola'], chain)).toBe(false); + expect(matchesPath(['hola', 'adios'], chain)).toBe(true); + }); + + it('should match simple route 4', () => { + const chain: RouteChain = [ + { id: '5', path: ['hola'], props: undefined }, + { id: '5', path: ['adios'], props: undefined }]; + + expect(matchesPath([''], chain)).toBe(false); + expect(matchesPath(['hola'], chain)).toBe(false); + expect(matchesPath(['hola', 'hola'], chain)).toBe(false); + expect(matchesPath(['hola', 'adios'], chain)).toBe(true); + }); +}); + +describe('routerPathToChain', () => { + it('should match the route with higher priority', () => { + const chain3: RouteChain = [{ id: '5', path: ['hola'], props: undefined }]; + const chain4: RouteChain = [ + { id: '5', path: ['hola'], props: undefined }, + { id: '5', path: ['adios'], props: undefined }]; + + const routes: RouteChain[] = [ + CHAIN_1, + CHAIN_2, + chain3, + chain4 + ]; + expect(routerPathToChain(['to'], routes)).toEqual({ + chain: null, + matches: 0, + }); + + expect(routerPathToChain([''], routes)).toEqual({ + chain: null, + matches: 0, + }); + expect(routerPathToChain(['segment', 'to'], routes)).toEqual({ + chain: null, + matches: 0, + }); + + expect(routerPathToChain(['hola'], routes)).toEqual({ + chain: chain3, + matches: 1, + }); + expect(routerPathToChain(['hola', 'hola'], routes)).toEqual({ + chain: chain3, + matches: 1, + }); + + expect(routerPathToChain(['hola', 'adios'], routes)).toEqual({ + chain: chain4, + matches: 2, + }); + + }); + + it('should match the default route', () => { + const chain1: RouteChain = [ + { id: 'tabs', path: [''], props: undefined }, + { id: 'tab1', path: [''], props: undefined }, + { id: 'schedule', path: [''], props: undefined } + ]; + const chain2: RouteChain = [ + { id: 'tabs', path: [''], props: undefined }, + { id: 'tab2', path: ['tab2'], props: undefined }, + { id: 'page2', path: [''], props: undefined } + ]; + + expect(routerPathToChain([''], [chain1])).toEqual({chain: chain1, matches: 3}); + expect(routerPathToChain(['tab2'], [chain1])).toEqual({chain: null, matches: 0}); + + expect(routerPathToChain([''], [chain2])).toEqual({chain: null, matches: 0}); + expect(routerPathToChain(['tab2'], [chain2])).toEqual({chain: chain2, matches: 3}); + + }); + + +}); + +// describe('matchRoute', () => { +// it('should match simple route', () => { +// const path = ['path', 'to', 'component']; +// const routes: RouteChain[] = [ +// [{ id: 2, path: ['to'], props: undefined }], +// [{ id: 1, path: ['path'], props: undefined }], +// [{ id: 3, path: ['segment'], props: undefined }], +// [{ id: 4, path: [''], props: undefined }], +// ]; +// const match = routerPathToChain(path, routes); +// expect(match).toEqual({ id: 1, path: ['path'], children: [] }); +// expect(seg.next()).toEqual('to'); +// }); + +// it('should match default route', () => { +// const routes: RouteTree = [ +// { id: 2, path: ['to'], children: [], props: undefined }, +// { id: 1, path: ['path'], children: [], props: undefined }, +// { id: 3, path: ['segment'], children: [], props: undefined }, +// { id: 4, path: [''], children: [], props: undefined }, +// ]; +// const seg = new RouterSegments(['hola', 'path']); +// let match = matchRoute(seg, routes); +// expect(match).toBeNull(); + +// match = matchRoute(seg, routes); +// expect(match.id).toEqual(1); + +// for (let i = 0; i < 20; i++) { +// match = matchRoute(seg, routes); +// expect(match.id).toEqual(4); +// } +// }); + +// it('should not match any route', () => { +// const routes: RouteTree = [ +// { id: 2, path: ['to', 'to', 'to'], children: [], props: undefined }, +// { id: 1, path: ['adam', 'manu'], children: [], props: undefined }, +// { id: 3, path: ['hola', 'adam'], children: [], props: undefined }, +// { id: 4, path: [''], children: [], props: undefined }, +// ]; +// const seg = new RouterSegments(['hola', 'manu', 'adam']); +// const match = matchRoute(seg, routes); +// expect(match).toBeNull(); +// }); + +// it('should not match if there are not routes', () => { +// const routes: RouteTree = []; +// const seg = new RouterSegments(['adam']); +// expect(matchRoute(seg, routes)).toBeNull(); +// expect(matchRoute(seg, routes)).toBeNull(); +// expect(matchRoute(seg, routes)).toBeNull(); +// }); + +// it('should not match any route (2)', () => { +// const routes: RouteTree = [ +// { id: 1, path: ['adam', 'manu'], children: [], props: undefined }, +// { id: 3, path: ['hola', 'adam'], children: [], props: undefined }, +// ]; +// const seg = new RouterSegments(['adam']); +// expect(matchRoute(seg, routes)).toBeNull(); +// expect(matchRoute(seg, routes)).toBeNull(); +// expect(matchRoute(seg, routes)).toBeNull(); +// }); + +// it ('should match multiple segments', () => { +// const routes: RouteTree = [ +// { id: 1, path: ['adam', 'manu'], children: [], props: undefined }, +// { id: 2, path: ['manu', 'hello'], children: [], props: undefined }, +// { id: 3, path: ['hello'], children: [], props: undefined }, +// { id: 4, path: [''], children: [], props: undefined }, +// ]; +// const seg = new RouterSegments(['adam', 'manu', 'hello', 'manu', 'hello']); +// let match = matchRoute(seg, routes); +// expect(match.id).toEqual(1); + +// match = matchRoute(seg, routes); +// expect(match.id).toEqual(3); + +// match = matchRoute(seg, routes); +// expect(match.id).toEqual(2); + +// match = matchRoute(seg, routes); +// expect(match.id).toEqual(4); + +// match = matchRoute(seg, routes); +// expect(match.id).toEqual(4); +// }); + +// it('should match long multi segments', () => { +// const routes: RouteTree = [ +// { id: 1, path: ['adam', 'manu', 'hello', 'menu', 'hello'], children: [], props: undefined }, +// { id: 2, path: ['adam', 'manu', 'hello', 'menu'], children: [], props: undefined }, +// { id: 3, path: ['adam', 'manu'], children: [], props: undefined }, +// ]; +// const seg = new RouterSegments(['adam', 'manu', 'hello', 'menu', 'hello']); +// const match = matchRoute(seg, routes); +// expect(match.id).toEqual(1); +// expect(matchRoute(seg, routes)).toBeNull(); +// }); + +// it('should match long multi segments', () => { +// let match = matchRoute(new RouterSegments(['']), null); +// expect(match).toBeNull(); + +// match = matchRoute(new RouterSegments(['hola']), null); +// expect(match).toBeNull(); +// }); +// }); diff --git a/packages/core/src/components/router/test/parser.spec.tsx b/packages/core/src/components/router/test/parser.spec.tsx new file mode 100644 index 0000000000..be163f33f0 --- /dev/null +++ b/packages/core/src/components/router/test/parser.spec.tsx @@ -0,0 +1,66 @@ +import { mockElement } from '@stencil/core/testing'; +import { flattenRouterTree, readRoutes } from '../utils/parser'; +import { RouteTree } from '../utils/interfaces'; + +describe('readRoutes', () => { + it('should read URL', () => { + const root = mockElement('div'); + const r1 = mockRouteElement('/', 'main-page'); + const r2 = mockRouteElement('/one-page', 'one-page'); + const r3 = mockRouteElement('secondpage', 'second-page'); + const r4 = mockRouteElement('/5/hola', '4'); + const r5 = mockRouteElement('/path/to/five', '5'); + const r6 = mockRouteElement('/path/to/five2', '6'); + + root.appendChild(r1); + root.appendChild(r2); + root.appendChild(r3); + r3.appendChild(r4); + r4.appendChild(r5); + r4.appendChild(r6); + + const expected: RouteTree = [ + { path: [''], id: 'main-page', children: [], props: undefined }, + { path: ['one-page'], id: 'one-page', children: [], props: undefined }, + { path: ['secondpage'], id: 'second-page', props: undefined, children: [ + { path: ['5', 'hola'], id: '4', props: undefined, children: [ + { path: ['path', 'to', 'five'], id: '5', children: [], props: undefined }, + { path: ['path', 'to', 'five2'], id: '6', children: [], props: undefined } + ] } + ] } + ]; + expect(readRoutes(root)).toEqual(expected); + }); +}); + +describe('flattenRouterTree', () => { + it('should process routes', () => { + const entries: RouteTree = [ + { path: [''], id: 'hola', children: [], props: undefined }, + { path: ['one-page'], id: 'one-page', children: [], props: undefined }, + { path: ['secondpage'], id: 'second-page', props: undefined, children: [ + { path: ['5', 'hola'], id: '4', props: undefined, children: [ + { path: ['path', 'to', 'five'], id: '5', children: [], props: undefined }, + { path: ['path', 'to', 'five2'], id: '6', children: [], props: undefined } + ] } + ] } + ]; + const routes = flattenRouterTree(entries); + expect(routes).toEqual([ + [{ path: [''], id: 'hola' }], + [{ path: ['one-page'], id: 'one-page' }], + [{ path: ['secondpage'], id: 'second-page'}, { path: ['5', 'hola'], id: '4'}, { path: ['path', 'to', 'five'], id: '5'}], + [{ path: ['secondpage'], id: 'second-page'}, { path: ['5', 'hola'], id: '4'}, { path: ['path', 'to', 'five2'], id: '6'}], + ]); + }); +}); + + + + +export function mockRouteElement(path: string, component: string) { + const el = mockElement('ion-route'); + el.setAttribute('path', path); + (el as any).component = component; + return el; +} diff --git a/packages/core/src/components/router/test/path.spec.tsx b/packages/core/src/components/router/test/path.spec.tsx new file mode 100644 index 0000000000..4fe3ff7ee4 --- /dev/null +++ b/packages/core/src/components/router/test/path.spec.tsx @@ -0,0 +1,55 @@ +import { generatePath, parsePath } from '../utils/path'; + +describe('parseURL', () => { + it('should parse empty path', () => { + expect(parsePath('')).toEqual(['']); + }); + + it('should parse empty path (2)', () => { + expect(parsePath(' ')).toEqual(['']); + }); + + it('should parse null path', () => { + expect(parsePath(null)).toEqual(['']); + }); + + it('should parse undefined path', () => { + expect(parsePath(undefined)).toEqual(['']); + }); + + it('should parse relative path', () => { + expect(parsePath('path/to/file.js')).toEqual(['path', 'to', 'file.js']); + }); + + it('should parse absolute path', () => { + expect(parsePath('/path/to/file.js')).toEqual(['path', 'to', 'file.js']); + }); + it('should parse relative path', () => { + expect(parsePath('/PATH///to//file.js//')).toEqual(['PATH', 'to', 'file.js']); + }); +}); + + +describe('generatePath', () => { + it('should generate an empty URL', () => { + expect(generatePath([])).toEqual('/'); + expect(generatePath([{ path: '' } as any])).toEqual('/'); + expect(generatePath([{ path: '/' } as any])).toEqual('/'); + expect(generatePath([{ path: '//' } as any])).toEqual('/'); + expect(generatePath([{ path: ' ' } as any])).toEqual('/'); + }); + + it('should genenerate a basic url', () => { + const stack = [ + '', + '', + '', + 'path/to', + 'page', + 'number-TWO', + '' + ]; + expect(generatePath(stack)).toEqual('/path/to/page/number-TWO'); + + }); +}); diff --git a/packages/core/src/components/router/test/router-utils.spec.tsx b/packages/core/src/components/router/test/router-utils.spec.tsx deleted file mode 100644 index 5caaaa49bd..0000000000 --- a/packages/core/src/components/router/test/router-utils.spec.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { - RouterEntries, RouterSegments, breadthFirstSearch, - generatePath, matchRoute, parsePath -} from '../router-utils'; - -describe('RouterSegments', () => { - it ('should initialize with empty array', () => { - const s = new RouterSegments([]); - expect(s.next()).toEqual(''); - expect(s.next()).toEqual(''); - expect(s.next()).toEqual(''); - expect(s.next()).toEqual(''); - expect(s.next()).toEqual(''); - }); - - it ('should initialize with array', () => { - const s = new RouterSegments(['', 'path', 'to', 'destination']); - expect(s.next()).toEqual(''); - expect(s.next()).toEqual('path'); - expect(s.next()).toEqual('to'); - expect(s.next()).toEqual('destination'); - expect(s.next()).toEqual(''); - expect(s.next()).toEqual(''); - }); -}); - -describe('parseURL', () => { - it('should parse empty path', () => { - expect(parsePath('')).toEqual(['']); - }); - - it('should parse empty path (2)', () => { - expect(parsePath(' ')).toEqual(['']); - }); - - it('should parse null path', () => { - expect(parsePath(null)).toEqual(['']); - }); - - it('should parse undefined path', () => { - expect(parsePath(undefined)).toEqual(['']); - }); - - it('should parse relative path', () => { - expect(parsePath('path/to/file.js')).toEqual(['path', 'to', 'file.js']); - }); - - it('should parse absolute path', () => { - expect(parsePath('/path/to/file.js')).toEqual(['path', 'to', 'file.js']); - }); - it('should parse relative path', () => { - expect(parsePath('/PATH///to//file.js//')).toEqual(['PATH', 'to', 'file.js']); - }); -}); - -// describe('readRoutes', () => { -// it('should read URL', () => { -// const node = (
-// -// -// -// -// -//
) as any; -// node.children = node.vchildren; - -// expect(readRoutes(node)).toEqual([ -// { path: [''], id: 'hola', subroutes: [] }, -// { path: ['one-page'], id: 'one-page', subroutes: [] }, -// { path: ['secondpage'], id: 'second-page', subroutes: [] }, -// { path: ['5', 'hola'], id: '4', subroutes: [] }, -// { path: ['path', 'to', 'five'], id: '5', subroutes: [] } -// ]); -// }); -// }); - -describe('matchRoute', () => { - it('should match simple route', () => { - const seg = new RouterSegments(['path', 'to', 'component']); - const routes: RouterEntries = [ - { id: 2, path: ['to'], subroutes: [] }, - { id: 1, path: ['path'], subroutes: [] }, - { id: 3, path: ['segment'], subroutes: [] }, - { id: 4, path: [''], subroutes: [] }, - ]; - const match = matchRoute(seg, routes); - expect(match).toEqual({ id: 1, path: ['path'], subroutes: [] }); - expect(seg.next()).toEqual('to'); - }); - - it('should match default route', () => { - const routes: RouterEntries = [ - { id: 2, path: ['to'], subroutes: [] }, - { id: 1, path: ['path'], subroutes: [] }, - { id: 3, path: ['segment'], subroutes: [] }, - { id: 4, path: [''], subroutes: [] }, - ]; - const seg = new RouterSegments(['hola', 'path']); - let match = matchRoute(seg, routes); - expect(match).toBeNull(); - - match = matchRoute(seg, routes); - expect(match.id).toEqual(1); - - for (let i = 0; i < 20; i++) { - match = matchRoute(seg, routes); - expect(match.id).toEqual(4); - } - }); - - it('should not match any route', () => { - const routes: RouterEntries = [ - { id: 2, path: ['to', 'to', 'to'], subroutes: [] }, - { id: 1, path: ['adam', 'manu'], subroutes: [] }, - { id: 3, path: ['hola', 'adam'], subroutes: [] }, - { id: 4, path: [''], subroutes: [] }, - ]; - const seg = new RouterSegments(['hola', 'manu', 'adam']); - const match = matchRoute(seg, routes); - expect(match).toBeNull(); - }); - - it('should not match if there are not routes', () => { - const routes: RouterEntries = []; - const seg = new RouterSegments(['adam']); - expect(matchRoute(seg, routes)).toBeNull(); - expect(matchRoute(seg, routes)).toBeNull(); - expect(matchRoute(seg, routes)).toBeNull(); - }); - - it('should not match any route (2)', () => { - const routes: RouterEntries = [ - { id: 1, path: ['adam', 'manu'], subroutes: [] }, - { id: 3, path: ['hola', 'adam'], subroutes: [] }, - ]; - const seg = new RouterSegments(['adam']); - expect(matchRoute(seg, routes)).toBeNull(); - expect(matchRoute(seg, routes)).toBeNull(); - expect(matchRoute(seg, routes)).toBeNull(); - }); - - it ('should match multiple segments', () => { - const routes: RouterEntries = [ - { id: 1, path: ['adam', 'manu'], subroutes: [] }, - { id: 2, path: ['manu', 'hello'], subroutes: [] }, - { id: 3, path: ['hello'], subroutes: [] }, - { id: 4, path: [''], subroutes: [] }, - ]; - const seg = new RouterSegments(['adam', 'manu', 'hello', 'manu', 'hello']); - let match = matchRoute(seg, routes); - expect(match.id).toEqual(1); - - match = matchRoute(seg, routes); - expect(match.id).toEqual(3); - - match = matchRoute(seg, routes); - expect(match.id).toEqual(2); - - match = matchRoute(seg, routes); - expect(match.id).toEqual(4); - - match = matchRoute(seg, routes); - expect(match.id).toEqual(4); - }); - - it('should match long multi segments', () => { - const routes: RouterEntries = [ - { id: 1, path: ['adam', 'manu', 'hello', 'menu', 'hello'], subroutes: [] }, - { id: 2, path: ['adam', 'manu', 'hello', 'menu'], subroutes: [] }, - { id: 3, path: ['adam', 'manu'], subroutes: [] }, - ]; - const seg = new RouterSegments(['adam', 'manu', 'hello', 'menu', 'hello']); - const match = matchRoute(seg, routes); - expect(match.id).toEqual(1); - expect(matchRoute(seg, routes)).toBeNull(); - }); - - it('should match long multi segments', () => { - let match = matchRoute(new RouterSegments(['']), null); - expect(match).toBeNull(); - - match = matchRoute(new RouterSegments(['hola']), null); - expect(match).toBeNull(); - }); -}); - - -describe('generatePath', () => { - it('should generate an empty URL', () => { - expect(generatePath([])).toEqual('/'); - expect(generatePath([{ path: '' } as any])).toEqual('/'); - expect(generatePath([{ path: '/' } as any])).toEqual('/'); - expect(generatePath([{ path: '//' } as any])).toEqual('/'); - expect(generatePath([{ path: ' ' } as any])).toEqual('/'); - }); - - it('should genenerate a basic url', () => { - const stack = [ - '', - '', - '', - 'path/to', - 'page', - 'number-TWO', - '' - ]; - expect(generatePath(stack)).toEqual('/path/to/page/number-TWO'); - - }); -}); - -describe('breadthFirstSearch', () => { - it('should search in order', () => { - const n1 = { tagName: 'ION-TABS', children: [] as any }; - const n2 = { tagName: 'DIV', children: [n1] }; - const n3 = { tagName: 'ION-NAV', children: [n2] }; - const n4 = { tagName: 'ION-TABS', children: [] as any }; - const n5 = { tagName: 'DIV', children: [n4] }; - const n6 = { tagName: 'DIV', children: [n5, n3] }; - const n7 = { tagName: 'DIV', children: [] as any }; - const n8 = { tagName: 'DIV', children: [n6] }; - const n9 = { tagName: 'DIV', children: [n8, n7] }; - - expect(breadthFirstSearch(n9 as any)).toBe(n3); - expect(breadthFirstSearch(n8 as any)).toBe(n3); - expect(breadthFirstSearch(n7 as any)).toBe(null); - expect(breadthFirstSearch(n6 as any)).toBe(n3); - expect(breadthFirstSearch(n5 as any)).toBe(n4); - expect(breadthFirstSearch(n4 as any)).toBe(n4); - expect(breadthFirstSearch(n3 as any)).toBe(n3); - expect(breadthFirstSearch(n2 as any)).toBe(n1); - expect(breadthFirstSearch(n1 as any)).toBe(n1); - }); -}); - diff --git a/packages/core/src/components/router/utils/common.ts b/packages/core/src/components/router/utils/common.ts new file mode 100644 index 0000000000..d8631c32b4 --- /dev/null +++ b/packages/core/src/components/router/utils/common.ts @@ -0,0 +1,51 @@ +import { NavOutletElement } from './interfaces'; + +export class RouterSegments { + private path: string[]; + constructor(path: string[]) { + this.path = path.slice(); + } + + isDefault(): boolean { + if (this.path.length > 0) { + return this.path[0] === ''; + } + return true; + } + + next(): string { + if (this.path.length > 0) { + return this.path.shift() as string; + } + return ''; + } +} + +const navs = ['ION-NAV', 'ION-TABS']; +export function breadthFirstSearch(root: HTMLElement): NavOutletElement | null { + if (!root) { + console.error('search root is null'); + return null; + } + // we do a Breadth-first search + // Breadth-first search (BFS) is an algorithm for traversing or searching tree + // or graph data structures.It starts at the tree root(or some arbitrary node of a graph, + // sometimes referred to as a 'search key'[1]) and explores the neighbor nodes + // first, before moving to the next level neighbours. + + const queue = [root]; + let node: HTMLElement | undefined; + while (node = queue.shift()) { + // visit node + if (navs.indexOf(node.tagName) >= 0) { + return node as NavOutletElement; + } + + // queue children + const children = node.children; + for (let i = 0; i < children.length; i++) { + queue.push(children[i] as NavOutletElement); + } + } + return null; +} diff --git a/packages/core/src/components/router/utils/dom.ts b/packages/core/src/components/router/utils/dom.ts new file mode 100644 index 0000000000..58811dfa1a --- /dev/null +++ b/packages/core/src/components/router/utils/dom.ts @@ -0,0 +1,48 @@ +import { breadthFirstSearch } from './common'; +import { NavOutlet, RouteChain } from './interfaces'; + +export function writeNavState(root: HTMLElement, chain: RouteChain, index: number, direction: number): Promise { + if (index >= chain.length) { + return Promise.resolve(); + } + const route = chain[index]; + const node = breadthFirstSearch(root); + if (!node) { + return Promise.resolve(); + } + return node.componentOnReady() + .then(() => node.setRouteId(route.id, route.props, direction)) + .then(changed => { + if (changed) { + direction = 0; + } + const nextEl = node.getContentElement(); + if (nextEl) { + return writeNavState(nextEl, chain, index + 1, direction); + } + return null; + }); +} + +export function readNavState(node: HTMLElement) { + const stack: string[] = []; + let pivot: NavOutlet|null; + while (true) { + pivot = breadthFirstSearch(node); + if (pivot) { + const cmp = pivot.getRouteId(); + if (cmp) { + node = pivot.getContentElement(); + stack.push(cmp.toLowerCase()); + } else { + break; + } + } else { + break; + } + } + return { + ids: stack, + pivot: pivot, + }; +} diff --git a/packages/core/src/components/router/utils/interfaces.ts b/packages/core/src/components/router/utils/interfaces.ts new file mode 100644 index 0000000000..27ff638cc5 --- /dev/null +++ b/packages/core/src/components/router/utils/interfaces.ts @@ -0,0 +1,26 @@ + +export interface NavOutlet { + setRouteId(id: any, data: any, direction: number): Promise; + getRouteId(): string; + getContentElement(): HTMLElement | null; +} + +export interface RouteMatch { + chain: RouteChain; + matches: number; +} + +export type NavOutletElement = NavOutlet & HTMLStencilElement; + +export interface RouteEntry { + id: string; + path: string[]; + props: any|undefined; +} + +export interface RouteNode extends RouteEntry { + children: RouteTree; +} + +export type RouteChain = RouteEntry[]; +export type RouteTree = RouteNode[]; diff --git a/packages/core/src/components/router/utils/matching.ts b/packages/core/src/components/router/utils/matching.ts new file mode 100644 index 0000000000..a188aba7d7 --- /dev/null +++ b/packages/core/src/components/router/utils/matching.ts @@ -0,0 +1,71 @@ +import { RouterSegments } from './common'; +import { RouteChain, RouteMatch } from './interfaces'; + +export function matchesIDs(ids: string[], chain: RouteChain): number { + const len = Math.min(ids.length, chain.length); + let i = 0; + for (; i < len; i++) { + if (ids[i] !== chain[i].id) { + break; + } + } + return i; +} + + +export function matchesPath(path: string[], chain: RouteChain): boolean { + const segments = new RouterSegments(path); + let matchesDefault = false; + for (let i = 0; i < chain.length; i++) { + const route = chain[i]; + if (route.path[0] !== '') { + for (const segment of route.path) { + if (segments.next() !== segment) { + return false; + } + } + matchesDefault = false; + } else { + matchesDefault = true; + } + } + if (matchesDefault) { + return matchesDefault === segments.isDefault(); + } + return true; +} + + +export function routerIDsToChain(ids: string[], chains: RouteChain[]): RouteMatch { + let match: RouteChain|null = null; + let maxMatches = 0; + for (const chain of chains) { + const score = matchesIDs(ids, chain); + if (score > maxMatches) { + match = chain; + maxMatches = score; + } + } + return { + chain: match, + matches: maxMatches, + }; +} + + +export function routerPathToChain(path: string[], chains: RouteChain[]): RouteMatch|null { + let match: RouteChain = null; + let matches = 0; + for (const chain of chains) { + if (matchesPath(path, chain)) { + if (chain.length > matches) { + matches = chain.length; + match = chain; + } + } + } + return { + chain: match, + matches, + }; +} diff --git a/packages/core/src/components/router/utils/parser.ts b/packages/core/src/components/router/utils/parser.ts new file mode 100644 index 0000000000..9ab783c143 --- /dev/null +++ b/packages/core/src/components/router/utils/parser.ts @@ -0,0 +1,49 @@ +import { RouteChain, RouteNode, RouteTree } from './interfaces'; +import { parsePath } from './path'; + + +export function readRoutes(root: Element): RouteTree { + return (Array.from(root.children) as HTMLIonRouteElement[]) + .filter(el => el.tagName === 'ION-ROUTE') + .map(el => ({ + path: parsePath(readProp(el, 'path')), + id: readProp(el, 'component'), + props: readProp(el, 'props'), + children: readRoutes(el) + })); +} + +export function readProp(el: HTMLElement, prop: string): string|undefined { + if (prop in el) { + return (el as any)[prop]; + } + if (el.hasAttribute(prop)) { + return el.getAttribute(prop); + } + return undefined; +} + +export function flattenRouterTree(nodes: RouteTree): RouteChain[] { + const routes: RouteChain[] = []; + for (const node of nodes) { + flattenNode([], routes, node); + } + return routes; +} + +function flattenNode(chain: RouteChain, routes: RouteChain[], node: RouteNode) { + const s = chain.slice(); + s.push({ + id: node.id, + path: node.path, + props: node.props + }); + + if (node.children.length === 0) { + routes.push(s); + return; + } + for (const sub of node.children) { + flattenNode(s, routes, sub); + } +} diff --git a/packages/core/src/components/router/utils/path.ts b/packages/core/src/components/router/utils/path.ts new file mode 100644 index 0000000000..9c5d436bf2 --- /dev/null +++ b/packages/core/src/components/router/utils/path.ts @@ -0,0 +1,62 @@ +import { RouteChain } from './interfaces'; + +export function generatePath(segments: string[]): string { + const path = segments + .filter(s => s.length > 0) + .join('/'); + + return '/' + path; +} + +export function chainToPath(chain: RouteChain): string[] { + const path = []; + for (const route of chain) { + if (route.path[0] !== '') { + path.push(...route.path); + } + } + return path; +} + +export function writePath(history: History, base: string, usePath: boolean, path: string[], isPop: boolean, state: number) { + path = [base, ...path]; + let url = generatePath(path); + if (usePath) { + url = '#' + url; + } + state++; + if (isPop) { + history.back(); + history.replaceState(state, null, url); + } else { + history.pushState(state, null, url); + } + return state; +} + +export function readPath(loc: Location, base: string, useHash: boolean): string[] | null { + const path = useHash + ? loc.hash.substr(1) + : loc.pathname; + + if (path.startsWith(base)) { + return parsePath(path.slice(base.length)); + } + return null; +} + +export function parsePath(path: string): string[] { + if (path === null || path === undefined) { + return ['']; + } + const segments = path.split('/') + .map(s => s.trim()) + .filter(s => s.length > 0); + + if (segments.length === 0) { + return ['']; + } else { + return segments; + } +} + diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts index c8aae2a6d9..fc1b63d894 100644 --- a/packages/core/src/index.d.ts +++ b/packages/core/src/index.d.ts @@ -74,10 +74,10 @@ export { Range, RangeEvent } from './components/range/range'; export { RangeKnob } from './components/range-knob/range-knob'; export { ReorderGroup } from './components/reorder-group/reorder-group'; export { - RouterEntry, - RouterEntries, + RouteNode, + RouteTree, NavOutlet -} from './components/router/router-utils'; +} from './components/router/utils/interfaces'; export { Row } from './components/row/row'; export { Reorder } from './components/reorder/reorder'; export { Scroll } from './components/scroll/scroll';