Skip to content

Commit eb2f5d7

Browse files
BREAKING CHANGE: Order URL Matching Rules by priority, not registration order
URL Rules can come from registered states' `.url`s, calling `.when()`, or calling `.rule()`. It's possible that two or more URL Rules could match the URL. ### Previously Previously, url rules were matched in the order in which they were registered. The rule which was registered first would handle the URL change. ### Now Now, the URL rules are sorted according to a sort function. More specific rules are preferred over less specific rules ### Why It's possible to have multiple url rules that match a given URL. Consider the following states: - `{ name: 'books', url: '/books/index' }''` - `{ name: 'book', url: '/books/:bookId' }''` Both states match when the url is `/books/index`. Additionally, you might have some custom url rewrite rules such as: `.when('/books/list', '/books/index')`. The `book` state also matches when the rewrite rule is matched. Previously, we simply used the first rule that matched. However, now that lazy loading is officially supported, it can be difficult for developers to ensure the rules are registered in the right order. Instead, we now prioritize url rules by how specific they are. More specific rules are matched earlier than less specific rules. We split the path on `/`. A static segment (such as `index` in the example) is more specific than a parameter (such as`:bookId`). ### More Details The built-in rule sorting function (see `UrlRouter.defaultRuleSortFn`) sorts rules in this order: - Explicit priority: `.when('/foo', '/bar', { priority: 1 })` (default priority is 0) - Rule Type: - UrlMatchers first (registered states and `.when(string, ...)`) - then regular Expressions (`.when(regexp, ...)`) - finally, everything else (`.rule()`) - UrlMatcher specificity: static path segments are more specific than variables (see `UrlMatcher.compare`) - Registration order (except for UrlMatcher based rules) For complete control, a custom sort function can be registered with `UrlService.rules.sort(sortFn)` ### Query params Because query parameters are optional, they are not considered during sorting. For example, both these rules will match when the url is `'/foo/bar'`: ``` .when('/foo/bar', doSomething); .when('/foo/bar?queryparam', doSomethingElse); ``` To choose the most specific rule, we match both rules, then choose the rule with the "best ratio" of matched optional parameters (see `UrlRuleFactory.fromUrlMatcher`) This allows child states to be defined with only query params for a URL. The state only activates when the query parameter is present. ``` .state('parent', { url: '/parent' }); .state('parent.child', { url: '?queryParam' }); ``` ## Restoring the previous behavior For backwards compatibility, register a sort function which sorts by the registration order: ```js myApp.config(function ($urlServiceProvider) { function sortByRegistrationOrder(a, b) { return a.$id - b.$id; } $urlServiceProvider.rules.sort(sortByRegistrationOrder); }); ``` --- feat(UrlRouter): sort url rules by specificity, not by registration order. refactor(UrlMatcher): Include own matcher in matcher._cache.path feat(UrlMatcher): Add comparison function by UrlMatcher specificity refactor(UrlRule): Use interface for UrlRules instead of extending classes
1 parent 7334d98 commit eb2f5d7

13 files changed

+852
-414
lines changed

src/common/common.ts

+57-4
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
* @module common
88
*/ /** for typedoc */
99

10-
import {isFunction, isString, isArray, isRegExp, isDate} from "./predicates";
11-
import { all, any, not, prop, curry } from "./hof";
12-
import {services} from "./coreservices";
13-
import {State} from "../state/stateObject";
10+
import { isFunction, isString, isArray, isRegExp, isDate } from "./predicates";
11+
import { all, any, prop, curry, val } from "./hof";
12+
import { services } from "./coreservices";
13+
import { State } from "../state/stateObject";
1414

1515
let w: any = typeof window === 'undefined' ? {} : window;
1616
let angular = w.angular || {};
@@ -607,6 +607,59 @@ function _arraysEq(a1: any[], a2: any[]) {
607607
return arrayTuples(a1, a2).reduce((b, t) => b && _equals(t[0], t[1]), true);
608608
}
609609

610+
export type sortfn = (a,b) => number;
611+
612+
/**
613+
* Create a sort function
614+
*
615+
* Creates a sort function which sorts by a numeric property.
616+
*
617+
* The `propFn` should return the property as a number which can be sorted.
618+
*
619+
* #### Example:
620+
* This example returns the `priority` prop.
621+
* ```js
622+
* var sortfn = sortBy(obj => obj.priority)
623+
* // equivalent to:
624+
* var longhandSortFn = (a, b) => a.priority - b.priority;
625+
* ```
626+
*
627+
* #### Example:
628+
* This example uses [[prop]]
629+
* ```js
630+
* var sortfn = sortBy(prop('priority'))
631+
* ```
632+
*
633+
* The `checkFn` can be used to exclude objects from sorting.
634+
*
635+
* #### Example:
636+
* This example only sorts objects with type === 'FOO'
637+
* ```js
638+
* var sortfn = sortBy(prop('priority'), propEq('type', 'FOO'))
639+
* ```
640+
*
641+
* @param propFn a function that returns the property (as a number)
642+
* @param checkFn a predicate
643+
*
644+
* @return a sort function like: `(a, b) => (checkFn(a) && checkFn(b)) ? propFn(a) - propFn(b) : 0`
645+
*/
646+
export const sortBy = (propFn: (a) => number, checkFn: Predicate<any> = val(true)) =>
647+
(a, b) =>
648+
(checkFn(a) && checkFn(b)) ? propFn(a) - propFn(b) : 0;
649+
650+
/**
651+
* Composes a list of sort functions
652+
*
653+
* Creates a sort function composed of multiple sort functions.
654+
* Each sort function is invoked in series.
655+
* The first sort function to return non-zero "wins".
656+
*
657+
* @param sortFns list of sort functions
658+
*/
659+
export const composeSort = (...sortFns: sortfn[]): sortfn =>
660+
(a, b) =>
661+
sortFns.reduce((prev, fn) => prev || fn(a, b), 0);
662+
610663
// issue #2676
611664
export const silenceUncaughtInPromise = (promise: Promise<any>) =>
612665
promise.catch(e => 0) && promise;

src/common/strings.ts

+43-7
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
* @module common_strings
77
*/ /** */
88

9-
import {isString, isArray, isDefined, isNull, isPromise, isInjectable, isObject} from "./predicates";
10-
import {Rejection} from "../transition/rejectFactory";
11-
import {IInjectable, identity, Obj} from "./common";
12-
import {pattern, is, not, val, invoke} from "./hof";
13-
import {Transition} from "../transition/transition";
14-
import {Resolvable} from "../resolve/resolvable";
9+
import { isString, isArray, isDefined, isNull, isPromise, isInjectable, isObject } from "./predicates";
10+
import { Rejection } from "../transition/rejectFactory";
11+
import { IInjectable, identity, Obj, tail, pushR } from "./common";
12+
import { pattern, is, not, val, invoke } from "./hof";
13+
import { Transition } from "../transition/transition";
14+
import { Resolvable } from "../resolve/resolvable";
1515

1616
/**
1717
* Returns a string shortened to a maximum length
@@ -116,4 +116,40 @@ export const beforeAfterSubstr = (char: string) => (str: string) => {
116116
let idx = str.indexOf(char);
117117
if (idx === -1) return [str, ""];
118118
return [str.substr(0, idx), str.substr(idx + 1)];
119-
};
119+
};
120+
121+
/**
122+
* Splits on a delimiter, but returns the delimiters in the array
123+
*
124+
* #### Example:
125+
* ```js
126+
* var splitOnSlashes = splitOnDelim('/');
127+
* splitOnSlashes("/foo"); // ["/", "foo"]
128+
* splitOnSlashes("/foo/"); // ["/", "foo", "/"]
129+
* ```
130+
*/
131+
export function splitOnDelim(delim: string) {
132+
let re = new RegExp("(" + delim + ")", "g");
133+
return (str: string) =>
134+
str.split(re).filter(identity);
135+
};
136+
137+
138+
/**
139+
* Reduce fn that joins neighboring strings
140+
*
141+
* Given an array of strings, returns a new array
142+
* where all neighboring strings have been joined.
143+
*
144+
* #### Example:
145+
* ```js
146+
* let arr = ["foo", "bar", 1, "baz", "", "qux" ];
147+
* arr.reduce(joinNeighborsR, []) // ["foobar", 1, "bazqux" ]
148+
* ```
149+
*/
150+
export function joinNeighborsR(acc: any[], x: any) {
151+
if (isString(tail(acc)) && isString(x))
152+
return acc.slice(0, -1).concat(tail(acc)+ x);
153+
return pushR(acc, x);
154+
};
155+

src/state/interface.ts

+15-11
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@
22
* @coreapi
33
* @module state
44
*/ /** for typedoc */
5-
import { ParamDeclaration, RawParams } from "../params/interface";
6-
7-
import {State} from "./stateObject";
8-
import {ViewContext} from "../view/interface";
9-
import {IInjectable} from "../common/common";
10-
import {Transition} from "../transition/transition";
11-
import {TransitionStateHookFn} from "../transition/interface";
12-
import {ResolvePolicy, ResolvableLiteral} from "../resolve/interface";
13-
import {Resolvable} from "../resolve/resolvable";
14-
import {ProviderLike} from "../resolve/interface";
15-
import {TargetState} from "./targetState";
5+
import { ParamDeclaration, RawParams, ParamsOrArray } from "../params/interface";
6+
import { State } from "./stateObject";
7+
import { ViewContext } from "../view/interface";
8+
import { IInjectable } from "../common/common";
9+
import { Transition } from "../transition/transition";
10+
import { TransitionStateHookFn, TransitionOptions } from "../transition/interface";
11+
import { ResolvePolicy, ResolvableLiteral, ProviderLike } from "../resolve/interface";
12+
import { Resolvable } from "../resolve/resolvable";
13+
import { TargetState } from "./targetState";
1614

1715
export type StateOrName = (string|StateDeclaration|State);
1816

@@ -21,6 +19,12 @@ export interface TransitionPromise extends Promise<State> {
2119
transition: Transition;
2220
}
2321

22+
export interface TargetStateDef {
23+
state: StateOrName;
24+
params?: ParamsOrArray;
25+
options?: TransitionOptions;
26+
}
27+
2428
export type ResolveTypes = Resolvable | ResolvableLiteral | ProviderLike;
2529
/**
2630
* Base interface for [[Ng1ViewDeclaration]] and [[Ng2ViewDeclaration]]

src/state/stateObject.ts

-3
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,6 @@ export class State {
4545
/** A compiled URLMatcher which detects when the state's URL is matched */
4646
public url: UrlMatcher;
4747

48-
/** @hidden temporary place to put the rule registered with $urlRouter.when() */
49-
public _urlRule: any;
50-
5148
/** The parameters for the state, built from the URL and [[StateDefinition.params]] */
5249
public params: { [key: string]: Param };
5350

src/state/stateQueueManager.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ export class StateQueueManager implements Disposable {
100100
attachRoute(state: State) {
101101
if (state.abstract || !state.url) return;
102102

103-
state._urlRule = this.$urlRouter.urlRuleFactory.fromState(state);
104-
this.$urlRouter.addRule(state._urlRule);
103+
this.$urlRouter.rule(this.$urlRouter.urlRuleFactory.create(state));
105104
}
106105
}

src/state/stateRegistry.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { StateQueueManager } from "./stateQueueManager";
1010
import { StateDeclaration } from "./interface";
1111
import { BuilderFunction } from "./stateBuilder";
1212
import { StateOrName } from "./interface";
13-
import { UrlRouter } from "../url/urlRouter";
1413
import { removeFrom } from "../common/common";
1514
import { UIRouter } from "../router";
15+
import { propEq } from "../common/hof";
1616

1717
/**
1818
* The signature for the callback function provided to [[StateRegistry.onStateRegistryEvent]].
@@ -31,13 +31,11 @@ export class StateRegistry {
3131
matcher: StateMatcher;
3232
private builder: StateBuilder;
3333
stateQueue: StateQueueManager;
34-
urlRouter: UrlRouter;
3534

3635
listeners: StateRegistryListener[] = [];
3736

3837
/** @internalapi */
3938
constructor(private _router: UIRouter) {
40-
this.urlRouter = _router.urlRouter;
4139
this.matcher = new StateMatcher(this.states);
4240
this.builder = new StateBuilder(this.matcher, _router.urlMatcherFactory);
4341
this.stateQueue = new StateQueueManager(this, _router.urlRouter, this.states, this.builder, this.listeners);
@@ -143,10 +141,13 @@ export class StateRegistry {
143141
};
144142

145143
let children = getChildren([state]);
146-
let deregistered = [state].concat(children).reverse();
144+
let deregistered: State[] = [state].concat(children).reverse();
147145

148146
deregistered.forEach(state => {
149-
this.urlRouter.removeRule(state._urlRule);
147+
let $ur = this._router.urlRouter;
148+
// Remove URL rule
149+
$ur.rules().filter(propEq("state", state)).forEach($ur.removeRule.bind($ur));
150+
// Remove state from registry
150151
delete this.states[state.name];
151152
});
152153

src/state/targetState.ts

+35-7
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
* @module state
44
*/ /** for typedoc */
55

6-
import {StateDeclaration, StateOrName} from "./interface";
7-
import {ParamsOrArray} from "../params/interface";
8-
import {TransitionOptions} from "../transition/interface";
9-
10-
import {State} from "./stateObject";
11-
import {toJson} from "../common/common";
6+
import { StateDeclaration, StateOrName, TargetStateDef } from "./interface";
7+
import { ParamsOrArray } from "../params/interface";
8+
import { TransitionOptions } from "../transition/interface";
9+
import { State } from "./stateObject";
10+
import { toJson } from "../common/common";
11+
import { isString } from "../common/predicates";
1212

1313
/**
1414
* Encapsulate the target (destination) state/params/options of a [[Transition]].
@@ -53,48 +53,59 @@ export class TargetState {
5353
* @param _definition The internal state representation, if exists.
5454
* @param _params Parameters for the target state
5555
* @param _options Transition options.
56+
*
57+
* @internalapi
5658
*/
5759
constructor(
5860
private _identifier: StateOrName,
5961
private _definition?: State,
60-
_params: ParamsOrArray = {},
62+
_params?: ParamsOrArray,
6163
private _options: TransitionOptions = {}
6264
) {
6365
this._params = _params || {};
6466
}
6567

68+
/** The name of the state this object targets */
6669
name(): String {
6770
return this._definition && this._definition.name || <String> this._identifier;
6871
}
6972

73+
/** The identifier used when creating this TargetState */
7074
identifier(): StateOrName {
7175
return this._identifier;
7276
}
7377

78+
/** The target parameter values */
7479
params(): ParamsOrArray {
7580
return this._params;
7681
}
7782

83+
/** The internal state object (if it was found) */
7884
$state(): State {
7985
return this._definition;
8086
}
8187

88+
/** The internal state declaration (if it was found) */
8289
state(): StateDeclaration {
8390
return this._definition && this._definition.self;
8491
}
8592

93+
/** The target options */
8694
options() {
8795
return this._options;
8896
}
8997

98+
/** True if the target state was found */
9099
exists(): boolean {
91100
return !!(this._definition && this._definition.self);
92101
}
93102

103+
/** True if the object is valid */
94104
valid(): boolean {
95105
return !this.error();
96106
}
97107

108+
/** If the object is invalid, returns the reason why */
98109
error(): string {
99110
let base = <any> this.options().relative;
100111
if (!this._definition && !!base) {
@@ -110,4 +121,21 @@ export class TargetState {
110121
toString() {
111122
return `'${this.name()}'${toJson(this.params())}`;
112123
}
124+
125+
/** Returns true if the object has a state property that might be a state or state name */
126+
static isDef = (obj): obj is TargetStateDef =>
127+
obj && obj.state && (isString(obj.state) || isString(obj.state.name));
128+
129+
// /** Returns a new TargetState based on this one, but using the specified options */
130+
// withOptions(_options: TransitionOptions): TargetState {
131+
// return extend(this._clone(), { _options });
132+
// }
133+
//
134+
// /** Returns a new TargetState based on this one, but using the specified params */
135+
// withParams(_params: ParamsOrArray): TargetState {
136+
// return extend(this._clone(), { _params });
137+
// }
138+
139+
// private _clone = () =>
140+
// new TargetState(this._identifier, this._definition, this._params, this._options);
113141
}

0 commit comments

Comments
 (0)