refactor(router): improve recognition and generation pipeline
This is a big change. @matsko also deserves much of the credit for the implementation. Previously, `ComponentInstruction`s held all the state for async components. Now, we introduce several subclasses for `Instruction` to describe each type of navigation. BREAKING CHANGE: Redirects now use the Link DSL syntax. Before: ``` @RouteConfig([ { path: '/foo', redirectTo: '/bar' }, { path: '/bar', component: BarCmp } ]) ``` After: ``` @RouteConfig([ { path: '/foo', redirectTo: ['Bar'] }, { path: '/bar', component: BarCmp, name: 'Bar' } ]) ``` BREAKING CHANGE: This also introduces `useAsDefault` in the RouteConfig, which makes cases like lazy-loading and encapsulating large routes with sub-routes easier. Previously, you could use `redirectTo` like this to expand a URL like `/tab` to `/tab/posts`: @RouteConfig([ { path: '/tab', redirectTo: '/tab/users' } { path: '/tab', component: TabsCmp, name: 'Tab' } ]) AppCmp { ... } Now the recommended way to handle this is case is to use `useAsDefault` like so: ``` @RouteConfig([ { path: '/tab', component: TabsCmp, name: 'Tab' } ]) AppCmp { ... } @RouteConfig([ { path: '/posts', component: PostsCmp, useAsDefault: true, name: 'Posts' }, { path: '/users', component: UsersCmp, name: 'Users' } ]) TabsCmp { ... } ``` In the above example, you can write just `['/Tab']` and the route `Users` is automatically selected as a child route. Closes #4170 Closes #4490 Closes #4694 Closes #5200 Closes #5352
This commit is contained in:
@ -1,6 +1,3 @@
|
||||
import {PathMatch} from './path_recognizer';
|
||||
import {RouteRecognizer} from './route_recognizer';
|
||||
import {Instruction, ComponentInstruction, PrimaryInstruction} from './instruction';
|
||||
import {ListWrapper, Map, MapWrapper, StringMapWrapper} from 'angular2/src/facade/collection';
|
||||
import {Promise, PromiseWrapper} from 'angular2/src/facade/async';
|
||||
import {
|
||||
@ -16,6 +13,7 @@ import {
|
||||
getTypeNameForDebugging
|
||||
} from 'angular2/src/facade/lang';
|
||||
import {BaseException, WrappedException} from 'angular2/src/facade/exceptions';
|
||||
import {reflector} from 'angular2/src/core/reflection/reflection';
|
||||
import {
|
||||
RouteConfig,
|
||||
AsyncRoute,
|
||||
@ -24,7 +22,16 @@ import {
|
||||
Redirect,
|
||||
RouteDefinition
|
||||
} from './route_config_impl';
|
||||
import {reflector} from 'angular2/src/core/reflection/reflection';
|
||||
import {PathMatch, RedirectMatch, RouteMatch} from './route_recognizer';
|
||||
import {ComponentRecognizer} from './component_recognizer';
|
||||
import {
|
||||
Instruction,
|
||||
ResolvedInstruction,
|
||||
RedirectInstruction,
|
||||
UnresolvedInstruction,
|
||||
DefaultInstruction
|
||||
} from './instruction';
|
||||
|
||||
import {Injectable} from 'angular2/angular2';
|
||||
import {normalizeRouteConfig, assertComponentExists} from './route_config_nomalizer';
|
||||
import {parser, Url, pathSegmentsToUrl} from './url_parser';
|
||||
@ -38,13 +45,13 @@ var _resolveToNull = PromiseWrapper.resolve(null);
|
||||
*/
|
||||
@Injectable()
|
||||
export class RouteRegistry {
|
||||
private _rules = new Map<any, RouteRecognizer>();
|
||||
private _rules = new Map<any, ComponentRecognizer>();
|
||||
|
||||
/**
|
||||
* Given a component and a configuration object, add the route to this registry
|
||||
*/
|
||||
config(parentComponent: any, config: RouteDefinition): void {
|
||||
config = normalizeRouteConfig(config);
|
||||
config = normalizeRouteConfig(config, this);
|
||||
|
||||
// this is here because Dart type guard reasons
|
||||
if (config instanceof Route) {
|
||||
@ -53,10 +60,10 @@ export class RouteRegistry {
|
||||
assertComponentExists(config.component, config.path);
|
||||
}
|
||||
|
||||
var recognizer: RouteRecognizer = this._rules.get(parentComponent);
|
||||
var recognizer: ComponentRecognizer = this._rules.get(parentComponent);
|
||||
|
||||
if (isBlank(recognizer)) {
|
||||
recognizer = new RouteRecognizer();
|
||||
recognizer = new ComponentRecognizer();
|
||||
this._rules.set(parentComponent, recognizer);
|
||||
}
|
||||
|
||||
@ -102,102 +109,162 @@ export class RouteRegistry {
|
||||
* Given a URL and a parent component, return the most specific instruction for navigating
|
||||
* the application into the state specified by the url
|
||||
*/
|
||||
recognize(url: string, parentComponent: any): Promise<Instruction> {
|
||||
recognize(url: string, ancestorComponents: any[]): Promise<Instruction> {
|
||||
var parsedUrl = parser.parse(url);
|
||||
return this._recognize(parsedUrl, parentComponent);
|
||||
return this._recognize(parsedUrl, ancestorComponents);
|
||||
}
|
||||
|
||||
private _recognize(parsedUrl: Url, parentComponent): Promise<Instruction> {
|
||||
return this._recognizePrimaryRoute(parsedUrl, parentComponent)
|
||||
.then((instruction: PrimaryInstruction) =>
|
||||
this._completeAuxiliaryRouteMatches(instruction, parentComponent));
|
||||
}
|
||||
|
||||
private _recognizePrimaryRoute(parsedUrl: Url, parentComponent): Promise<PrimaryInstruction> {
|
||||
/**
|
||||
* Recognizes all parent-child routes, but creates unresolved auxiliary routes
|
||||
*/
|
||||
|
||||
private _recognize(parsedUrl: Url, ancestorComponents: any[],
|
||||
_aux = false): Promise<Instruction> {
|
||||
var parentComponent = ancestorComponents[ancestorComponents.length - 1];
|
||||
var componentRecognizer = this._rules.get(parentComponent);
|
||||
if (isBlank(componentRecognizer)) {
|
||||
return _resolveToNull;
|
||||
}
|
||||
|
||||
// Matches some beginning part of the given URL
|
||||
var possibleMatches = componentRecognizer.recognize(parsedUrl);
|
||||
var possibleMatches: Promise<RouteMatch>[] =
|
||||
_aux ? componentRecognizer.recognizeAuxiliary(parsedUrl) :
|
||||
componentRecognizer.recognize(parsedUrl);
|
||||
|
||||
var matchPromises =
|
||||
possibleMatches.map(candidate => this._completePrimaryRouteMatch(candidate));
|
||||
var matchPromises: Promise<Instruction>[] = possibleMatches.map(
|
||||
(candidate: Promise<RouteMatch>) => candidate.then((candidate: RouteMatch) => {
|
||||
|
||||
if (candidate instanceof PathMatch) {
|
||||
if (candidate.instruction.terminal) {
|
||||
var unresolvedAux =
|
||||
this._auxRoutesToUnresolved(candidate.remainingAux, parentComponent);
|
||||
return new ResolvedInstruction(candidate.instruction, null, unresolvedAux);
|
||||
}
|
||||
|
||||
var newAncestorComponents =
|
||||
ancestorComponents.concat([candidate.instruction.componentType]);
|
||||
|
||||
return this._recognize(candidate.remaining, newAncestorComponents)
|
||||
.then((childInstruction) => {
|
||||
if (isBlank(childInstruction)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// redirect instructions are already absolute
|
||||
if (childInstruction instanceof RedirectInstruction) {
|
||||
return childInstruction;
|
||||
}
|
||||
var unresolvedAux =
|
||||
this._auxRoutesToUnresolved(candidate.remainingAux, parentComponent);
|
||||
return new ResolvedInstruction(candidate.instruction, childInstruction,
|
||||
unresolvedAux);
|
||||
});
|
||||
}
|
||||
|
||||
if (candidate instanceof RedirectMatch) {
|
||||
var instruction = this.generate(candidate.redirectTo, ancestorComponents);
|
||||
return new RedirectInstruction(instruction.component, instruction.child,
|
||||
instruction.auxInstruction);
|
||||
}
|
||||
}));
|
||||
|
||||
if ((isBlank(parsedUrl) || parsedUrl.path == '') && possibleMatches.length == 0) {
|
||||
return PromiseWrapper.resolve(this.generateDefault(parentComponent));
|
||||
}
|
||||
|
||||
return PromiseWrapper.all(matchPromises).then(mostSpecific);
|
||||
}
|
||||
|
||||
private _completePrimaryRouteMatch(partialMatch: PathMatch): Promise<PrimaryInstruction> {
|
||||
var instruction = partialMatch.instruction;
|
||||
return instruction.resolveComponentType().then((componentType) => {
|
||||
this.configFromComponent(componentType);
|
||||
private _auxRoutesToUnresolved(auxRoutes: Url[], parentComponent): {[key: string]: Instruction} {
|
||||
var unresolvedAuxInstructions: {[key: string]: Instruction} = {};
|
||||
|
||||
if (instruction.terminal) {
|
||||
return new PrimaryInstruction(instruction, null, partialMatch.remainingAux);
|
||||
}
|
||||
|
||||
return this._recognizePrimaryRoute(partialMatch.remaining, componentType)
|
||||
.then((childInstruction) => {
|
||||
if (isBlank(childInstruction)) {
|
||||
return null;
|
||||
} else {
|
||||
return new PrimaryInstruction(instruction, childInstruction,
|
||||
partialMatch.remainingAux);
|
||||
}
|
||||
});
|
||||
auxRoutes.forEach((auxUrl: Url) => {
|
||||
unresolvedAuxInstructions[auxUrl.path] = new UnresolvedInstruction(
|
||||
() => { return this._recognize(auxUrl, [parentComponent], true); });
|
||||
});
|
||||
|
||||
return unresolvedAuxInstructions;
|
||||
}
|
||||
|
||||
|
||||
private _completeAuxiliaryRouteMatches(instruction: PrimaryInstruction,
|
||||
parentComponent: any): Promise<Instruction> {
|
||||
if (isBlank(instruction)) {
|
||||
return _resolveToNull;
|
||||
}
|
||||
|
||||
var componentRecognizer = this._rules.get(parentComponent);
|
||||
var auxInstructions: {[key: string]: Instruction} = {};
|
||||
|
||||
var promises = instruction.auxUrls.map((auxSegment: Url) => {
|
||||
var match = componentRecognizer.recognizeAuxiliary(auxSegment);
|
||||
if (isBlank(match)) {
|
||||
return _resolveToNull;
|
||||
}
|
||||
return this._completePrimaryRouteMatch(match).then((auxInstruction: PrimaryInstruction) => {
|
||||
if (isPresent(auxInstruction)) {
|
||||
return this._completeAuxiliaryRouteMatches(auxInstruction, parentComponent)
|
||||
.then((finishedAuxRoute: Instruction) => {
|
||||
auxInstructions[auxSegment.path] = finishedAuxRoute;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return PromiseWrapper.all(promises).then((_) => {
|
||||
if (isBlank(instruction.child)) {
|
||||
return new Instruction(instruction.component, null, auxInstructions);
|
||||
}
|
||||
return this._completeAuxiliaryRouteMatches(instruction.child,
|
||||
instruction.component.componentType)
|
||||
.then((completeChild) => {
|
||||
return new Instruction(instruction.component, completeChild, auxInstructions);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a normalized list with component names and params like: `['user', {id: 3 }]`
|
||||
* generates a url with a leading slash relative to the provided `parentComponent`.
|
||||
*
|
||||
* If the optional param `_aux` is `true`, then we generate starting at an auxiliary
|
||||
* route boundary.
|
||||
*/
|
||||
generate(linkParams: any[], parentComponent: any, _aux = false): Instruction {
|
||||
generate(linkParams: any[], ancestorComponents: any[], _aux = false): Instruction {
|
||||
let parentComponent = ancestorComponents[ancestorComponents.length - 1];
|
||||
let grandparentComponent =
|
||||
ancestorComponents.length > 1 ? ancestorComponents[ancestorComponents.length - 2] : null;
|
||||
|
||||
let normalizedLinkParams = splitAndFlattenLinkParams(linkParams);
|
||||
|
||||
var first = ListWrapper.first(normalizedLinkParams);
|
||||
var rest = ListWrapper.slice(normalizedLinkParams, 1);
|
||||
|
||||
// The first segment should be either '.' (generate from parent) or '' (generate from root).
|
||||
// When we normalize above, we strip all the slashes, './' becomes '.' and '/' becomes ''.
|
||||
if (first == '') {
|
||||
var firstComponent = ancestorComponents[0];
|
||||
ListWrapper.clear(ancestorComponents);
|
||||
ancestorComponents.push(firstComponent);
|
||||
} else if (first == '..') {
|
||||
// we already captured the first instance of "..", so we need to pop off an ancestor
|
||||
ancestorComponents.pop();
|
||||
while (ListWrapper.first(rest) == '..') {
|
||||
rest = ListWrapper.slice(rest, 1);
|
||||
ancestorComponents.pop();
|
||||
if (ancestorComponents.length <= 0) {
|
||||
throw new BaseException(
|
||||
`Link "${ListWrapper.toJSON(linkParams)}" has too many "../" segments.`);
|
||||
}
|
||||
}
|
||||
} else if (first != '.') {
|
||||
// For a link with no leading `./`, `/`, or `../`, we look for a sibling and child.
|
||||
// If both exist, we throw. Otherwise, we prefer whichever exists.
|
||||
var childRouteExists = this.hasRoute(first, parentComponent);
|
||||
var parentRouteExists =
|
||||
isPresent(grandparentComponent) && this.hasRoute(first, grandparentComponent);
|
||||
|
||||
if (parentRouteExists && childRouteExists) {
|
||||
let msg =
|
||||
`Link "${ListWrapper.toJSON(linkParams)}" is ambiguous, use "./" or "../" to disambiguate.`;
|
||||
throw new BaseException(msg);
|
||||
}
|
||||
if (parentRouteExists) {
|
||||
ancestorComponents.pop();
|
||||
}
|
||||
rest = linkParams;
|
||||
}
|
||||
|
||||
if (rest[rest.length - 1] == '') {
|
||||
rest.pop();
|
||||
}
|
||||
|
||||
if (rest.length < 1) {
|
||||
let msg = `Link "${ListWrapper.toJSON(linkParams)}" must include a route name.`;
|
||||
throw new BaseException(msg);
|
||||
}
|
||||
|
||||
return this._generate(rest, ancestorComponents, _aux);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Internal helper that does not make any assertions about the beginning of the link DSL
|
||||
*/
|
||||
private _generate(linkParams: any[], ancestorComponents: any[], _aux = false): Instruction {
|
||||
let parentComponent = ancestorComponents[ancestorComponents.length - 1];
|
||||
|
||||
if (linkParams.length == 0) {
|
||||
return this.generateDefault(parentComponent);
|
||||
}
|
||||
let linkIndex = 0;
|
||||
let routeName = linkParams[linkIndex];
|
||||
|
||||
// TODO: this is kind of odd but it makes existing assertions pass
|
||||
if (isBlank(parentComponent)) {
|
||||
throw new BaseException(`Could not find route named "${routeName}".`);
|
||||
}
|
||||
|
||||
if (!isString(routeName)) {
|
||||
throw new BaseException(`Unexpected segment "${routeName}" in link DSL. Expected a string.`);
|
||||
} else if (routeName == '' || routeName == '.' || routeName == '..') {
|
||||
@ -216,7 +283,10 @@ export class RouteRegistry {
|
||||
let auxInstructions: {[key: string]: Instruction} = {};
|
||||
var nextSegment;
|
||||
while (linkIndex + 1 < linkParams.length && isArray(nextSegment = linkParams[linkIndex + 1])) {
|
||||
auxInstructions[nextSegment[0]] = this.generate(nextSegment, parentComponent, true);
|
||||
let auxInstruction = this._generate(nextSegment, [parentComponent], true);
|
||||
|
||||
// TODO: this will not work for aux routes with parameters or multiple segments
|
||||
auxInstructions[auxInstruction.component.urlPath] = auxInstruction;
|
||||
linkIndex += 1;
|
||||
}
|
||||
|
||||
@ -226,74 +296,105 @@ export class RouteRegistry {
|
||||
`Component "${getTypeNameForDebugging(parentComponent)}" has no route config.`);
|
||||
}
|
||||
|
||||
var componentInstruction = _aux ? componentRecognizer.generateAuxiliary(routeName, params) :
|
||||
componentRecognizer.generate(routeName, params);
|
||||
var routeRecognizer =
|
||||
(_aux ? componentRecognizer.auxNames : componentRecognizer.names).get(routeName);
|
||||
|
||||
if (isBlank(componentInstruction)) {
|
||||
if (!isPresent(routeRecognizer)) {
|
||||
throw new BaseException(
|
||||
`Component "${getTypeNameForDebugging(parentComponent)}" has no route named "${routeName}".`);
|
||||
}
|
||||
|
||||
var childInstruction = null;
|
||||
if (linkIndex + 1 < linkParams.length) {
|
||||
var remaining = linkParams.slice(linkIndex + 1);
|
||||
childInstruction = this.generate(remaining, componentInstruction.componentType);
|
||||
} else if (!componentInstruction.terminal) {
|
||||
throw new BaseException(
|
||||
`Link "${ListWrapper.toJSON(linkParams)}" does not resolve to a terminal or async instruction.`);
|
||||
if (!isPresent(routeRecognizer.handler.componentType)) {
|
||||
var compInstruction = routeRecognizer.generateComponentPathValues(params);
|
||||
return new UnresolvedInstruction(() => {
|
||||
return routeRecognizer.handler.resolveComponentType().then(
|
||||
(_) => { return this._generate(linkParams, ancestorComponents, _aux); });
|
||||
}, compInstruction['urlPath'], compInstruction['urlParams']);
|
||||
}
|
||||
|
||||
return new Instruction(componentInstruction, childInstruction, auxInstructions);
|
||||
var componentInstruction = _aux ? componentRecognizer.generateAuxiliary(routeName, params) :
|
||||
componentRecognizer.generate(routeName, params);
|
||||
|
||||
|
||||
var childInstruction: Instruction = null;
|
||||
|
||||
var remaining = linkParams.slice(linkIndex + 1);
|
||||
|
||||
// the component is sync
|
||||
if (isPresent(componentInstruction.componentType)) {
|
||||
if (linkIndex + 1 < linkParams.length) {
|
||||
let childAncestorComponents =
|
||||
ancestorComponents.concat([componentInstruction.componentType]);
|
||||
childInstruction = this._generate(remaining, childAncestorComponents);
|
||||
} else if (!componentInstruction.terminal) {
|
||||
// ... look for defaults
|
||||
childInstruction = this.generateDefault(componentInstruction.componentType);
|
||||
|
||||
if (isBlank(childInstruction)) {
|
||||
throw new BaseException(
|
||||
`Link "${ListWrapper.toJSON(linkParams)}" does not resolve to a terminal instruction.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ResolvedInstruction(componentInstruction, childInstruction, auxInstructions);
|
||||
}
|
||||
|
||||
public hasRoute(name: string, parentComponent: any): boolean {
|
||||
var componentRecognizer: RouteRecognizer = this._rules.get(parentComponent);
|
||||
var componentRecognizer: ComponentRecognizer = this._rules.get(parentComponent);
|
||||
if (isBlank(componentRecognizer)) {
|
||||
return false;
|
||||
}
|
||||
return componentRecognizer.hasRoute(name);
|
||||
}
|
||||
|
||||
// if the child includes a redirect like : "/" -> "/something",
|
||||
// we want to honor that redirection when creating the link
|
||||
private _generateRedirects(componentCursor: Type): Instruction {
|
||||
public generateDefault(componentCursor: Type): Instruction {
|
||||
if (isBlank(componentCursor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var componentRecognizer = this._rules.get(componentCursor);
|
||||
if (isBlank(componentRecognizer)) {
|
||||
if (isBlank(componentRecognizer) || isBlank(componentRecognizer.defaultRoute)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < componentRecognizer.redirects.length; i += 1) {
|
||||
let redirect = componentRecognizer.redirects[i];
|
||||
|
||||
// we only handle redirecting from an empty segment
|
||||
if (redirect.segments.length == 1 && redirect.segments[0] == '') {
|
||||
var toSegments = pathSegmentsToUrl(redirect.toSegments);
|
||||
var matches = componentRecognizer.recognize(toSegments);
|
||||
var primaryInstruction =
|
||||
ListWrapper.maximum(matches, (match: PathMatch) => match.instruction.specificity);
|
||||
|
||||
if (isPresent(primaryInstruction)) {
|
||||
var child = this._generateRedirects(primaryInstruction.instruction.componentType);
|
||||
return new Instruction(primaryInstruction.instruction, child, {});
|
||||
}
|
||||
return null;
|
||||
var defaultChild = null;
|
||||
if (isPresent(componentRecognizer.defaultRoute.handler.componentType)) {
|
||||
var componentInstruction = componentRecognizer.defaultRoute.generate({});
|
||||
if (!componentRecognizer.defaultRoute.terminal) {
|
||||
defaultChild = this.generateDefault(componentRecognizer.defaultRoute.handler.componentType);
|
||||
}
|
||||
return new DefaultInstruction(componentInstruction, defaultChild);
|
||||
}
|
||||
|
||||
return null;
|
||||
return new UnresolvedInstruction(() => {
|
||||
return componentRecognizer.defaultRoute.handler.resolveComponentType().then(
|
||||
() => this.generateDefault(componentCursor));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Given: ['/a/b', {c: 2}]
|
||||
* Returns: ['', 'a', 'b', {c: 2}]
|
||||
*/
|
||||
function splitAndFlattenLinkParams(linkParams: any[]): any[] {
|
||||
return linkParams.reduce((accumulation: any[], item) => {
|
||||
if (isString(item)) {
|
||||
let strItem: string = item;
|
||||
return accumulation.concat(strItem.split('/'));
|
||||
}
|
||||
accumulation.push(item);
|
||||
return accumulation;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/*
|
||||
* Given a list of instructions, returns the most specific instruction
|
||||
*/
|
||||
function mostSpecific(instructions: PrimaryInstruction[]): PrimaryInstruction {
|
||||
return ListWrapper.maximum(
|
||||
instructions, (instruction: PrimaryInstruction) => instruction.component.specificity);
|
||||
function mostSpecific(instructions: Instruction[]): Instruction {
|
||||
return ListWrapper.maximum(instructions, (instruction: Instruction) => instruction.specificity);
|
||||
}
|
||||
|
||||
function assertTerminalComponent(component, path) {
|
||||
|
Reference in New Issue
Block a user