fix(router): correctly sort route matches with children by specificity

This changes the way we calculate specificity. Instead of using a number,
we use a string, so that combining specificity across parent-child instructions
becomes a matter of concatenating them

Fixes #5848

Closes #6011
This commit is contained in:
Brian Ford 2015-12-18 10:05:55 -08:00
parent e748adda2e
commit b2bc50dbd1
7 changed files with 80 additions and 26 deletions

View File

@ -11,6 +11,7 @@ class Math {
static final _random = new math.Random(); static final _random = new math.Random();
static int floor(num n) => n.floor(); static int floor(num n) => n.floor();
static double random() => _random.nextDouble(); static double random() => _random.nextDouble();
static num min(num a, num b) => math.min(a, b);
} }
class CONST { class CONST {

View File

@ -115,8 +115,8 @@ export abstract class Instruction {
get urlParams(): string[] { return isPresent(this.component) ? this.component.urlParams : []; } get urlParams(): string[] { return isPresent(this.component) ? this.component.urlParams : []; }
get specificity(): number { get specificity(): string {
var total = 0; var total = '';
if (isPresent(this.component)) { if (isPresent(this.component)) {
total += this.component.specificity; total += this.component.specificity;
} }
@ -305,7 +305,7 @@ export class ComponentInstruction {
public routeData: RouteData; public routeData: RouteData;
constructor(public urlPath: string, public urlParams: string[], data: RouteData, constructor(public urlPath: string, public urlParams: string[], data: RouteData,
public componentType, public terminal: boolean, public specificity: number, public componentType, public terminal: boolean, public specificity: string,
public params: {[key: string]: any} = null) { public params: {[key: string]: any} = null) {
this.routeData = isPresent(data) ? data : BLANK_ROUTE_DATA; this.routeData = isPresent(data) ? data : BLANK_ROUTE_DATA;
} }

View File

@ -96,21 +96,22 @@ function parsePathString(route: string): {[key: string]: any} {
var segments = splitBySlash(route); var segments = splitBySlash(route);
var results = []; var results = [];
var specificity = 0;
var specificity = '';
// a single slash (or "empty segment" is as specific as a static segment
if (segments.length == 0) {
specificity += '2';
}
// The "specificity" of a path is used to determine which route is used when multiple routes match // The "specificity" of a path is used to determine which route is used when multiple routes match
// a URL. // a URL. Static segments (like "/foo") are the most specific, followed by dynamic segments (like
// Static segments (like "/foo") are the most specific, followed by dynamic segments (like // "/:id"). Star segments add no specificity. Segments at the start of the path are more specific
// "/:id"). Star segments // than proceeding ones.
// add no specificity. Segments at the start of the path are more specific than proceeding ones. //
// The code below uses place values to combine the different types of segments into a single // The code below uses place values to combine the different types of segments into a single
// integer that we can // string that we can sort later. Each static segment is marked as a specificity of "2," each
// sort later. Each static segment is worth hundreds of points of specificity (10000, 9900, ..., // dynamic segment is worth "1" specificity, and stars are worth "0" specificity.
// 200), and each
// dynamic segment is worth single points of specificity (100, 99, ... 2).
if (segments.length > 98) {
throw new BaseException(`'${route}' has more than the maximum supported number of segments.`);
}
var limit = segments.length - 1; var limit = segments.length - 1;
for (var i = 0; i <= limit; i++) { for (var i = 0; i <= limit; i++) {
@ -118,9 +119,10 @@ function parsePathString(route: string): {[key: string]: any} {
if (isPresent(match = RegExpWrapper.firstMatch(paramMatcher, segment))) { if (isPresent(match = RegExpWrapper.firstMatch(paramMatcher, segment))) {
results.push(new DynamicSegment(match[1])); results.push(new DynamicSegment(match[1]));
specificity += (100 - i); specificity += '1';
} else if (isPresent(match = RegExpWrapper.firstMatch(wildcardMatcher, segment))) { } else if (isPresent(match = RegExpWrapper.firstMatch(wildcardMatcher, segment))) {
results.push(new StarSegment(match[1])); results.push(new StarSegment(match[1]));
specificity += '0';
} else if (segment == '...') { } else if (segment == '...') {
if (i < limit) { if (i < limit) {
throw new BaseException(`Unexpected "..." before the end of the path for "${route}".`); throw new BaseException(`Unexpected "..." before the end of the path for "${route}".`);
@ -128,13 +130,11 @@ function parsePathString(route: string): {[key: string]: any} {
results.push(new ContinuationSegment()); results.push(new ContinuationSegment());
} else { } else {
results.push(new StaticSegment(segment)); results.push(new StaticSegment(segment));
specificity += 100 * (100 - i); specificity += '2';
} }
} }
var result = StringMapWrapper.create();
StringMapWrapper.set(result, 'segments', results); return {'segments': results, 'specificity': specificity};
StringMapWrapper.set(result, 'specificity', specificity);
return result;
} }
// this function is used to determine whether a route config path like `/foo/:id` collides with // this function is used to determine whether a route config path like `/foo/:id` collides with
@ -177,7 +177,7 @@ function assertPath(path: string) {
*/ */
export class PathRecognizer { export class PathRecognizer {
private _segments: Segment[]; private _segments: Segment[];
specificity: number; specificity: string;
terminal: boolean = true; terminal: boolean = true;
hash: string; hash: string;

View File

@ -59,7 +59,7 @@ export class RedirectRecognizer implements AbstractRecognizer {
// represents something like '/foo/:bar' // represents something like '/foo/:bar'
export class RouteRecognizer implements AbstractRecognizer { export class RouteRecognizer implements AbstractRecognizer {
specificity: number; specificity: string;
terminal: boolean = true; terminal: boolean = true;
hash: string; hash: string;

View File

@ -8,6 +8,8 @@ import {
isString, isString,
isStringMap, isStringMap,
Type, Type,
StringWrapper,
Math,
getTypeNameForDebugging, getTypeNameForDebugging,
CONST_EXPR CONST_EXPR
} from 'angular2/src/facade/lang'; } from 'angular2/src/facade/lang';
@ -492,7 +494,39 @@ function splitAndFlattenLinkParams(linkParams: any[]): any[] {
* Given a list of instructions, returns the most specific instruction * Given a list of instructions, returns the most specific instruction
*/ */
function mostSpecific(instructions: Instruction[]): Instruction { function mostSpecific(instructions: Instruction[]): Instruction {
return ListWrapper.maximum(instructions, (instruction: Instruction) => instruction.specificity); instructions = instructions.filter((instruction) => isPresent(instruction));
if (instructions.length == 0) {
return null;
}
if (instructions.length == 1) {
return instructions[0];
}
var first = instructions[0];
var rest = instructions.slice(1);
return rest.reduce((instruction: Instruction, contender: Instruction) => {
if (compareSpecificityStrings(contender.specificity, instruction.specificity) == -1) {
return contender;
}
return instruction;
}, first);
}
/*
* Expects strings to be in the form of "[0-2]+"
* Returns -1 if string A should be sorted above string B, 1 if it should be sorted after,
* or 0 if they are the same.
*/
function compareSpecificityStrings(a: string, b: string): number {
var l = Math.min(a.length, b.length);
for (var i = 0; i < l; i += 1) {
var ai = StringWrapper.charCodeAt(a, i);
var bi = StringWrapper.charCodeAt(b, i);
var difference = bi - ai;
if (difference != 0) {
return difference;
}
}
return a.length - b.length;
} }
function assertTerminalComponent(component, path) { function assertTerminalComponent(component, path) {

View File

@ -181,6 +181,21 @@ export function main() {
}); });
})); }));
it('should prefer routes with high specificity over routes with children with lower specificity',
inject([AsyncTestCompleter], (async) => {
registry.config(RootHostCmp, new Route({path: '/first', component: DummyCmpA}));
// terminates to DummyCmpB
registry.config(RootHostCmp,
new Route({path: '/:second/...', component: SingleSlashChildCmp}));
registry.recognize('/first', [])
.then((instruction) => {
expect(instruction.component.componentType).toBe(DummyCmpA);
async.done();
});
}));
it('should match the full URL using child components', inject([AsyncTestCompleter], (async) => { it('should match the full URL using child components', inject([AsyncTestCompleter], (async) => {
registry.config(RootHostCmp, new Route({path: '/first/...', component: DummyParentCmp})); registry.config(RootHostCmp, new Route({path: '/first/...', component: DummyParentCmp}));
@ -322,6 +337,10 @@ class DummyCmpB {}
class DefaultRouteCmp { class DefaultRouteCmp {
} }
@RouteConfig([new Route({path: '/', component: DummyCmpB, name: 'ThirdCmp'})])
class SingleSlashChildCmp {
}
@RouteConfig([ @RouteConfig([
new Route( new Route(

View File

@ -33,8 +33,8 @@ import {
import {DOM} from 'angular2/src/platform/dom/dom_adapter'; import {DOM} from 'angular2/src/platform/dom/dom_adapter';
import {ResolvedInstruction} from 'angular2/src/router/instruction'; import {ResolvedInstruction} from 'angular2/src/router/instruction';
let dummyInstruction = let dummyInstruction = new ResolvedInstruction(
new ResolvedInstruction(new ComponentInstruction('detail', [], null, null, true, 0), null, {}); new ComponentInstruction('detail', [], null, null, true, '0'), null, {});
export function main() { export function main() {
describe('routerLink directive', function() { describe('routerLink directive', function() {