Skip to content

Commit 2d2ae9b

Browse files
committed
feat(router): enforce usage of ... syntax for parent to child component routes
1 parent fa7a3e3 commit 2d2ae9b

File tree

8 files changed

+103
-27
lines changed

8 files changed

+103
-27
lines changed

modules/angular2/src/mock/location_mock.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export class SpyLocation extends SpyObject {
1616

1717
constructor() {
1818
super();
19-
this._path = '/';
19+
this._path = '';
2020
this.urlChanges = [];
2121
this._subject = new EventEmitter();
2222
this._baseHref = '';

modules/angular2/src/router/path_recognizer.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export class Segment {
2727
regex: string;
2828
}
2929

30+
export class ContinuationSegment extends Segment {
31+
generate(params): string { return ''; }
32+
}
33+
3034
class StaticSegment extends Segment {
3135
regex: string;
3236
name: string;
@@ -71,7 +75,7 @@ var wildcardMatcher = RegExpWrapper.create("^\\*([^\/]+)$");
7175
function parsePathString(route: string) {
7276
// normalize route as not starting with a "/". Recognition will
7377
// also normalize.
74-
if (route[0] === "/") {
78+
if (StringWrapper.startsWith(route, "/")) {
7579
route = StringWrapper.substring(route, 1);
7680
}
7781

@@ -93,14 +97,21 @@ function parsePathString(route: string) {
9397
throw new BaseException(`'${route}' has more than the maximum supported number of segments.`);
9498
}
9599

96-
for (var i = 0; i < segments.length; i++) {
100+
var limit = segments.length - 1;
101+
for (var i = 0; i <= limit; i++) {
97102
var segment = segments[i], match;
98103

99104
if (isPresent(match = RegExpWrapper.firstMatch(paramMatcher, segment))) {
100105
results.push(new DynamicSegment(match[1]));
101106
specificity += (100 - i);
102107
} else if (isPresent(match = RegExpWrapper.firstMatch(wildcardMatcher, segment))) {
103108
results.push(new StarSegment(match[1]));
109+
} else if (segment == '...') {
110+
if (i < limit) {
111+
// TODO (matsko): setup a proper error here `
112+
throw new BaseException(`Unexpected "..." before the end of the path for "${route}".`);
113+
}
114+
results.push(new ContinuationSegment());
104115
} else if (segment.length > 0) {
105116
results.push(new StaticSegment(segment));
106117
specificity += 100 * (100 - i);
@@ -120,6 +131,7 @@ export class PathRecognizer {
120131
segments: List<Segment>;
121132
regex: RegExp;
122133
specificity: number;
134+
terminal: boolean = true;
123135

124136
constructor(public path: string, public handler: any) {
125137
this.segments = [];
@@ -131,7 +143,17 @@ export class PathRecognizer {
131143
var segments = parsed['segments'];
132144
var regexString = '^';
133145

134-
ListWrapper.forEach(segments, (segment) => { regexString += '/' + segment.regex; });
146+
ListWrapper.forEach(segments, (segment) => {
147+
if (segment instanceof ContinuationSegment) {
148+
this.terminal = false;
149+
} else {
150+
regexString += '/' + segment.regex;
151+
}
152+
});
153+
154+
if (this.terminal) {
155+
regexString += '$';
156+
}
135157

136158
this.regex = RegExpWrapper.create(regexString);
137159
this.segments = segments;
@@ -143,6 +165,10 @@ export class PathRecognizer {
143165
var urlPart = url;
144166
for (var i = 0; i < this.segments.length; i++) {
145167
var segment = this.segments[i];
168+
if (segment instanceof ContinuationSegment) {
169+
continue;
170+
}
171+
146172
var match = RegExpWrapper.firstMatch(RegExpWrapper.create('/' + segment.regex), urlPart);
147173
urlPart = StringWrapper.substring(urlPart, match[0].length);
148174
if (segment.name.length > 0) {

modules/angular2/src/router/route_recognizer.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
StringMapWrapper
1515
} from 'angular2/src/facade/collection';
1616

17-
import {PathRecognizer} from './path_recognizer';
17+
import {PathRecognizer, ContinuationSegment} from './path_recognizer';
1818

1919
/**
2020
* `RouteRecognizer` is responsible for recognizing routes for a single component.
@@ -32,9 +32,14 @@ export class RouteRecognizer {
3232
this.redirects = new Map();
3333
}
3434

35-
addRedirect(path: string, target: string): void { this.redirects.set(path, target); }
35+
addRedirect(path: string, target: string): void {
36+
if (path == '/') {
37+
path = '';
38+
}
39+
this.redirects.set(path, target);
40+
}
3641

37-
addConfig(path: string, handler: any, alias: string = null): void {
42+
addConfig(path: string, handler: any, alias: string = null): boolean {
3843
var recognizer = new PathRecognizer(path, handler);
3944
MapWrapper.forEach(this.matchers, (matcher, _) => {
4045
if (recognizer.regex.toString() == matcher.regex.toString()) {
@@ -46,6 +51,7 @@ export class RouteRecognizer {
4651
if (isPresent(alias)) {
4752
this.names.set(alias, recognizer);
4853
}
54+
return recognizer.terminal;
4955
}
5056

5157

@@ -55,6 +61,9 @@ export class RouteRecognizer {
5561
*/
5662
recognize(url: string): List<RouteMatch> {
5763
var solutions = [];
64+
if (url.length > 0 && url[url.length - 1] == '/') {
65+
url = url.substring(0, url.length - 1);
66+
}
5867

5968
MapWrapper.forEach(this.redirects, (target, path) => {
6069
// "/" redirect case

modules/angular2/src/router/route_registry.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,17 @@ export class RouteRegistry {
5353
config, {'component': normalizeComponentDeclaration(config['component'])});
5454

5555
var component = config['component'];
56-
this.configFromComponent(component);
56+
var terminal = recognizer.addConfig(config['path'], config, config['as']);
5757

58-
recognizer.addConfig(config['path'], config, config['as']);
58+
if (component['type'] == 'constructor') {
59+
if (terminal) {
60+
assertTerminalComponent(component['constructor'], config['path']);
61+
} else {
62+
this.configFromComponent(component['constructor']);
63+
}
64+
}
5965
}
6066

61-
6267
/**
6368
* Reads the annotations of a component and configures the registry based on them
6469
*/
@@ -221,3 +226,21 @@ function mostSpecific(instructions: List<Instruction>): Instruction {
221226
}
222227
return mostSpecificSolution;
223228
}
229+
230+
function assertTerminalComponent(component, path) {
231+
if (!isType(component)) {
232+
return;
233+
}
234+
235+
var annotations = reflector.annotations(component);
236+
if (isPresent(annotations)) {
237+
for (var i = 0; i < annotations.length; i++) {
238+
var annotation = annotations[i];
239+
240+
if (annotation instanceof RouteConfig) {
241+
throw new BaseException(
242+
`Child routes are not allowed for "${path}". Use "..." on the parent's route path.`);
243+
}
244+
}
245+
}
246+
}

modules/angular2/test/router/outlet_spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export function main() {
100100

101101
it('should work with child routers', inject([AsyncTestCompleter], (async) => {
102102
compile('outer { <router-outlet></router-outlet> }')
103-
.then((_) => rtr.config({'path': '/a', 'component': ParentCmp}))
103+
.then((_) => rtr.config({'path': '/a/...', 'component': ParentCmp}))
104104
.then((_) => rtr.navigate('/a/b'))
105105
.then((_) => {
106106
view.detectChanges();
@@ -153,7 +153,7 @@ export function main() {
153153

154154
it('should reuse common parent components', inject([AsyncTestCompleter], (async) => {
155155
compile()
156-
.then((_) => rtr.config({'path': '/team/:id', 'component': TeamCmp}))
156+
.then((_) => rtr.config({'path': '/team/:id/...', 'component': TeamCmp}))
157157
.then((_) => rtr.navigate('/team/angular/user/rado'))
158158
.then((_) => {
159159
view.detectChanges();

modules/angular2/test/router/route_recognizer_spec.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,21 +87,24 @@ export function main() {
8787
expect(solution.matchedUrl).toEqual('/bar');
8888
});
8989

90-
it('should perform a valid redirect when a slash or an empty string is being processed', () => {
91-
recognizer.addRedirect('/', '/matias');
92-
recognizer.addRedirect('', '/fatias');
90+
it('should perform a root URL redirect when only a slash or an empty string is being processed',
91+
() => {
92+
recognizer.addRedirect('/', '/matias');
93+
recognizer.addConfig('/matias', handler);
9394

94-
recognizer.addConfig('/matias', handler);
95-
recognizer.addConfig('/fatias', handler);
95+
recognizer.addConfig('/fatias', handler);
9696

97-
var solutions;
97+
var solutions;
9898

99-
solutions = recognizer.recognize('/');
100-
expect(solutions[0].matchedUrl).toBe('/matias');
99+
solutions = recognizer.recognize('/');
100+
expect(solutions[0].matchedUrl).toBe('/matias');
101101

102-
solutions = recognizer.recognize('');
103-
expect(solutions[0].matchedUrl).toBe('/fatias');
104-
});
102+
solutions = recognizer.recognize('/fatias');
103+
expect(solutions[0].matchedUrl).toBe('/fatias');
104+
105+
solutions = recognizer.recognize('');
106+
expect(solutions[0].matchedUrl).toBe('/matias');
107+
});
105108

106109
it('should generate URLs', () => {
107110
recognizer.addConfig('/app/user/:name', handler, 'user');

modules/angular2/test/router/route_registry_spec.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function main() {
9191
}));
9292

9393
it('should match the full URL using child components', inject([AsyncTestCompleter], (async) => {
94-
registry.config(rootHostComponent, {'path': '/first', 'component': DummyParentComp});
94+
registry.config(rootHostComponent, {'path': '/first/...', 'component': DummyParentComp});
9595

9696
registry.recognize('/first/second', rootHostComponent)
9797
.then((instruction) => {
@@ -103,7 +103,7 @@ export function main() {
103103

104104
it('should match the URL using async child components',
105105
inject([AsyncTestCompleter], (async) => {
106-
registry.config(rootHostComponent, {'path': '/first', 'component': DummyAsyncComp});
106+
registry.config(rootHostComponent, {'path': '/first/...', 'component': DummyAsyncComp});
107107

108108
registry.recognize('/first/second', rootHostComponent)
109109
.then((instruction) => {
@@ -117,7 +117,7 @@ export function main() {
117117
inject([AsyncTestCompleter], (async) => {
118118
registry.config(
119119
rootHostComponent,
120-
{'path': '/first', 'component': {'loader': AsyncParentLoader, 'type': 'loader'}});
120+
{'path': '/first/...', 'component': {'loader': AsyncParentLoader, 'type': 'loader'}});
121121

122122
registry.recognize('/first/second', rootHostComponent)
123123
.then((instruction) => {
@@ -139,6 +139,21 @@ export function main() {
139139
{'path': '/some/path', 'component': {'type': 'intentionallyWrongComponentType'}}))
140140
.toThrowError('Invalid component type \'intentionallyWrongComponentType\'');
141141
});
142+
143+
it('should throw when a parent config is missing the `...` suffix any of its children add routes',
144+
() => {
145+
expect(() =>
146+
registry.config(rootHostComponent, {'path': '/', 'component': DummyParentComp}))
147+
.toThrowError(
148+
'Child routes are not allowed for "/". Use "..." on the parent\'s route path.');
149+
});
150+
151+
it('should throw when a parent config is missing the `...` suffix any of its children add routes',
152+
() => {
153+
expect(() => registry.config(rootHostComponent,
154+
{'path': '/home/.../fun/', 'component': DummyParentComp}))
155+
.toThrowError('Unexpected "..." before the end of the path for "home/.../fun/".');
156+
});
142157
});
143158
}
144159

modules/angular2/test/router/router_integration_spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class ParentCmp {
122122

123123
@Component({selector: 'app-cmp'})
124124
@View({template: `root { <router-outlet></router-outlet> }`, directives: routerDirectives})
125-
@RouteConfig([{path: '/parent', component: ParentCmp}])
125+
@RouteConfig([{path: '/parent/...', component: ParentCmp}])
126126
class HierarchyAppCmp {
127127
constructor(public router: Router, public location: BrowserLocation) {}
128128
}

0 commit comments

Comments
 (0)