angular-cn/packages/router/src/recognize.ts

310 lines
12 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
2016-06-08 11:13:41 -07:00
import {Type} from '@angular/core';
import {Observable, Observer, of} from 'rxjs';
2016-06-08 11:13:41 -07:00
import {Data, ResolveData, Route, Routes} from './config';
import {ActivatedRouteSnapshot, inheritedParamsDataResolve, ParamsInheritanceStrategy, RouterStateSnapshot} from './router_state';
import {PRIMARY_OUTLET} from './shared';
import {UrlSegment, UrlSegmentGroup, UrlTree} from './url_tree';
import {last} from './utils/collection';
import {getOutlet, sortByMatchingOutlets} from './utils/config';
import {isImmediateMatch, match, noLeftoversInUrl, split} from './utils/config_matching';
2016-06-08 11:13:41 -07:00
import {TreeNode} from './utils/tree';
2016-05-23 16:14:23 -07:00
class NoMatch {}
function newObservableError(e: unknown): Observable<RouterStateSnapshot> {
// TODO(atscott): This pattern is used throughout the router code and can be `throwError` instead.
return new Observable<RouterStateSnapshot>((obs: Observer<RouterStateSnapshot>) => obs.error(e));
}
export function recognize(
rootComponentType: Type<any>|null, config: Routes, urlTree: UrlTree, url: string,
paramsInheritanceStrategy: ParamsInheritanceStrategy = 'emptyOnly',
relativeLinkResolution: 'legacy'|'corrected' = 'legacy'): Observable<RouterStateSnapshot> {
try {
const result = new Recognizer(
rootComponentType, config, urlTree, url, paramsInheritanceStrategy,
relativeLinkResolution)
.recognize();
if (result === null) {
return newObservableError(new NoMatch());
} else {
return of(result);
}
} catch (e) {
// Catch the potential error from recognize due to duplicate outlet matches and return as an
// `Observable` error instead.
return newObservableError(e);
}
2016-05-23 16:14:23 -07:00
}
export class Recognizer {
constructor(
private rootComponentType: Type<any>|null, private config: Routes, private urlTree: UrlTree,
private url: string, private paramsInheritanceStrategy: ParamsInheritanceStrategy,
private relativeLinkResolution: 'legacy'|'corrected') {}
2016-06-14 14:55:59 -07:00
recognize(): RouterStateSnapshot|null {
const rootSegmentGroup =
split(
this.urlTree.root, [], [], this.config.filter(c => c.redirectTo === undefined),
this.relativeLinkResolution)
.segmentGroup;
2016-06-14 14:55:59 -07:00
const children = this.processSegmentGroup(this.config, rootSegmentGroup, PRIMARY_OUTLET);
if (children === null) {
return null;
}
// Use Object.freeze to prevent readers of the Router state from modifying it outside of a
// navigation, resulting in the router being out of sync with the browser.
const root = new ActivatedRouteSnapshot(
[], Object.freeze({}), Object.freeze({...this.urlTree.queryParams}), this.urlTree.fragment!,
{}, PRIMARY_OUTLET, this.rootComponentType, null, this.urlTree.root, -1, {});
2016-06-02 11:30:38 -07:00
const rootNode = new TreeNode<ActivatedRouteSnapshot>(root, children);
const routeState = new RouterStateSnapshot(this.url, rootNode);
this.inheritParamsAndData(routeState._root);
return routeState;
}
2016-05-23 16:14:23 -07:00
inheritParamsAndData(routeNode: TreeNode<ActivatedRouteSnapshot>): void {
const route = routeNode.value;
const i = inheritedParamsDataResolve(route, this.paramsInheritanceStrategy);
route.params = Object.freeze(i.params);
route.data = Object.freeze(i.data);
routeNode.children.forEach(n => this.inheritParamsAndData(n));
}
processSegmentGroup(config: Route[], segmentGroup: UrlSegmentGroup, outlet: string):
TreeNode<ActivatedRouteSnapshot>[]|null {
if (segmentGroup.segments.length === 0 && segmentGroup.hasChildren()) {
return this.processChildren(config, segmentGroup);
}
return this.processSegment(config, segmentGroup, segmentGroup.segments, outlet);
}
2016-06-14 14:55:59 -07:00
/**
* Matches every child outlet in the `segmentGroup` to a `Route` in the config. Returns `null` if
* we cannot find a match for _any_ of the children.
*
* @param config - The `Routes` to match against
* @param segmentGroup - The `UrlSegmentGroup` whose children need to be matched against the
* config.
*/
processChildren(config: Route[], segmentGroup: UrlSegmentGroup):
TreeNode<ActivatedRouteSnapshot>[]|null {
const children: Array<TreeNode<ActivatedRouteSnapshot>> = [];
for (const childOutlet of Object.keys(segmentGroup.children)) {
const child = segmentGroup.children[childOutlet];
// Sort the config so that routes with outlets that match the one being activated appear
// first, followed by routes for other outlets, which might match if they have an empty path.
const sortedConfig = sortByMatchingOutlets(config, childOutlet);
const outletChildren = this.processSegmentGroup(sortedConfig, child, childOutlet);
if (outletChildren === null) {
// Configs must match all segment children so because we did not find a match for this
// outlet, return `null`.
return null;
}
children.push(...outletChildren);
}
// Because we may have matched two outlets to the same empty path segment, we can have multiple
// activated results for the same outlet. We should merge the children of these results so the
// final return value is only one `TreeNode` per outlet.
const mergedChildren = mergeEmptyPathMatches(children);
if (typeof ngDevMode === 'undefined' || ngDevMode) {
// This should really never happen - we are only taking the first match for each outlet and
// merge the empty path matches.
checkOutletNameUniqueness(mergedChildren);
}
sortActivatedRouteSnapshots(mergedChildren);
return mergedChildren;
}
processSegment(
config: Route[], segmentGroup: UrlSegmentGroup, segments: UrlSegment[],
outlet: string): TreeNode<ActivatedRouteSnapshot>[]|null {
for (const r of config) {
const children = this.processSegmentAgainstRoute(r, segmentGroup, segments, outlet);
if (children !== null) {
return children;
}
}
if (noLeftoversInUrl(segmentGroup, segments, outlet)) {
return [];
}
return null;
}
processSegmentAgainstRoute(
route: Route, rawSegment: UrlSegmentGroup, segments: UrlSegment[],
outlet: string): TreeNode<ActivatedRouteSnapshot>[]|null {
if (route.redirectTo || !isImmediateMatch(route, rawSegment, segments, outlet)) return null;
2016-06-14 14:55:59 -07:00
let snapshot: ActivatedRouteSnapshot;
let consumedSegments: UrlSegment[] = [];
let rawSlicedSegments: UrlSegment[] = [];
if (route.path === '**') {
const params = segments.length > 0 ? last(segments)!.parameters : {};
snapshot = new ActivatedRouteSnapshot(
segments, params, Object.freeze({...this.urlTree.queryParams}), this.urlTree.fragment!,
getData(route), getOutlet(route), route.component!, route,
getSourceSegmentGroup(rawSegment), getPathIndexShift(rawSegment) + segments.length,
getResolve(route));
} else {
const result = match(rawSegment, route, segments);
if (!result.matched) {
return null;
}
consumedSegments = result.consumedSegments;
rawSlicedSegments = segments.slice(result.lastChild);
snapshot = new ActivatedRouteSnapshot(
consumedSegments, result.parameters, Object.freeze({...this.urlTree.queryParams}),
this.urlTree.fragment!, getData(route), getOutlet(route), route.component!, route,
getSourceSegmentGroup(rawSegment),
getPathIndexShift(rawSegment) + consumedSegments.length, getResolve(route));
}
const childConfig: Route[] = getChildConfig(route);
2016-06-14 14:55:59 -07:00
const {segmentGroup, slicedSegments} = split(
rawSegment, consumedSegments, rawSlicedSegments,
// Filter out routes with redirectTo because we are trying to create activated route
// snapshots and don't handle redirects here. That should have been done in
// `applyRedirects`.
childConfig.filter(c => c.redirectTo === undefined), this.relativeLinkResolution);
if (slicedSegments.length === 0 && segmentGroup.hasChildren()) {
const children = this.processChildren(childConfig, segmentGroup);
if (children === null) {
return null;
}
return [new TreeNode<ActivatedRouteSnapshot>(snapshot, children)];
}
if (childConfig.length === 0 && slicedSegments.length === 0) {
return [new TreeNode<ActivatedRouteSnapshot>(snapshot, [])];
}
const matchedOnOutlet = getOutlet(route) === outlet;
// If we matched a config due to empty path match on a different outlet, we need to continue
// passing the current outlet for the segment rather than switch to PRIMARY.
// Note that we switch to primary when we have a match because outlet configs look like this:
// {path: 'a', outlet: 'a', children: [
// {path: 'b', component: B},
// {path: 'c', component: C},
// ]}
// Notice that the children of the named outlet are configured with the primary outlet
const children = this.processSegment(
childConfig, segmentGroup, slicedSegments, matchedOnOutlet ? PRIMARY_OUTLET : outlet);
if (children === null) {
return null;
}
return [new TreeNode<ActivatedRouteSnapshot>(snapshot, children)];
2016-05-23 16:14:23 -07:00
}
}
function sortActivatedRouteSnapshots(nodes: TreeNode<ActivatedRouteSnapshot>[]): void {
nodes.sort((a, b) => {
if (a.value.outlet === PRIMARY_OUTLET) return -1;
if (b.value.outlet === PRIMARY_OUTLET) return 1;
return a.value.outlet.localeCompare(b.value.outlet);
});
}
function getChildConfig(route: Route): Route[] {
if (route.children) {
return route.children;
}
if (route.loadChildren) {
return route._loadedConfig!.routes;
}
return [];
}
function hasEmptyPathConfig(node: TreeNode<ActivatedRouteSnapshot>) {
const config = node.value.routeConfig;
return config && config.path === '' && config.redirectTo === undefined;
}
/**
* Finds `TreeNode`s with matching empty path route configs and merges them into `TreeNode` with the
* children from each duplicate. This is necessary because different outlets can match a single
* empty path route config and the results need to then be merged.
*/
function mergeEmptyPathMatches(nodes: Array<TreeNode<ActivatedRouteSnapshot>>):
Array<TreeNode<ActivatedRouteSnapshot>> {
const result: Array<TreeNode<ActivatedRouteSnapshot>> = [];
for (const node of nodes) {
if (!hasEmptyPathConfig(node)) {
result.push(node);
continue;
}
const duplicateEmptyPathNode =
result.find(resultNode => node.value.routeConfig === resultNode.value.routeConfig);
if (duplicateEmptyPathNode !== undefined) {
duplicateEmptyPathNode.children.push(...node.children);
} else {
result.push(node);
}
}
return result;
}
2016-06-14 14:55:59 -07:00
function checkOutletNameUniqueness(nodes: TreeNode<ActivatedRouteSnapshot>[]): void {
const names: {[k: string]: ActivatedRouteSnapshot} = {};
2016-06-14 14:55:59 -07:00
nodes.forEach(n => {
const routeWithSameOutletName = names[n.value.outlet];
2016-06-14 14:55:59 -07:00
if (routeWithSameOutletName) {
const p = routeWithSameOutletName.url.map(s => s.toString()).join('/');
2016-06-14 14:55:59 -07:00
const c = n.value.url.map(s => s.toString()).join('/');
throw new Error(`Two segments cannot have the same outlet name: '${p}' and '${c}'.`);
}
names[n.value.outlet] = n.value;
});
}
function getSourceSegmentGroup(segmentGroup: UrlSegmentGroup): UrlSegmentGroup {
let s = segmentGroup;
while (s._sourceSegment) {
s = s._sourceSegment;
}
return s;
}
function getPathIndexShift(segmentGroup: UrlSegmentGroup): number {
let s = segmentGroup;
let res = (s._segmentIndexShift ? s._segmentIndexShift : 0);
while (s._sourceSegment) {
s = s._sourceSegment;
res += (s._segmentIndexShift ? s._segmentIndexShift : 0);
}
return res - 1;
}
function getData(route: Route): Data {
return route.data || {};
}
function getResolve(route: Route): ResolveData {
return route.resolve || {};
}