Skip to content

Commit

Permalink
fix(ion-router): fixes routing algorithm
Browse files Browse the repository at this point in the history
  • Loading branch information
manucorporat committed Mar 6, 2018
1 parent dc56a59 commit c8a27b7
Show file tree
Hide file tree
Showing 15 changed files with 840 additions and 480 deletions.
229 changes: 0 additions & 229 deletions packages/core/src/components/router/router-utils.ts
Original file line number Diff line number Diff line change
@@ -1,229 +0,0 @@
export interface NavOutlet {
setRouteId(id: any, data: any, direction: number): Promise<boolean>;
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<void> {
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;
}
31 changes: 18 additions & 13 deletions packages/core/src/components/router/router.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
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({
tag: 'ion-router'
})
export class Router {

private routes: RouterEntries;
private routes: RouteChain[];
private busy = false;
private state = 0;

Expand All @@ -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(() => {
Expand All @@ -49,31 +54,30 @@ 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<any> {
const node = document.querySelector('ion-app');
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<any> {
const chain = matchRouteChain(path, routes);

private writeNavState(node: any, chain: RouteChain, direction: number): Promise<any> {
this.busy = true;
return writeNavState(node, chain, 0, direction)
.catch(err => console.error(err))
Expand All @@ -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);
}

Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/components/router/test/common.spec.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});

Loading

0 comments on commit c8a27b7

Please sign in to comment.