Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(router): Add more find-tuned control in routerLinkActiveOptions #40303

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 10 additions & 2 deletions goldens/public-api/router/router.d.ts
Expand Up @@ -158,6 +158,13 @@ export declare class GuardsCheckStart extends RouterEvent {

export declare type InitialNavigation = 'disabled' | 'enabled' | 'enabledBlocking' | 'enabledNonBlocking';

export declare interface IsActiveMatchOptions {
fragment: 'exact' | 'ignored';
matrixParams: 'exact' | 'subset' | 'ignored';
paths: 'exact' | 'subset';
queryParams: 'exact' | 'subset' | 'ignored';
}

export declare type LoadChildren = LoadChildrenCallback | DeprecatedLoadChildren;

export declare type LoadChildrenCallback = () => Type<any> | NgModuleFactory<any> | Observable<Type<any>> | Promise<NgModuleFactory<any> | Type<any> | any>;
Expand Down Expand Up @@ -345,7 +352,8 @@ export declare class Router {
dispose(): void;
getCurrentNavigation(): Navigation | null;
initialNavigation(): void;
isActive(url: string | UrlTree, exact: boolean): boolean;
/** @deprecated */ isActive(url: string | UrlTree, exact: boolean): boolean;
isActive(url: string | UrlTree, matchOptions: IsActiveMatchOptions): boolean;
navigate(commands: any[], extras?: NavigationExtras): Promise<boolean>;
navigateByUrl(url: string | UrlTree, extras?: NavigationBehaviorOptions): Promise<boolean>;
ngOnDestroy(): void;
Expand Down Expand Up @@ -400,7 +408,7 @@ export declare class RouterLinkActive implements OnChanges, OnDestroy, AfterCont
set routerLinkActive(data: string[] | string);
routerLinkActiveOptions: {
exact: boolean;
};
} | IsActiveMatchOptions;
constructor(router: Router, element: ElementRef, renderer: Renderer2, cdr: ChangeDetectorRef, link?: RouterLink | undefined, linkWithHref?: RouterLinkWithHref | undefined);
ngAfterContentInit(): void;
ngOnChanges(changes: SimpleChanges): void;
Expand Down
4 changes: 2 additions & 2 deletions goldens/size-tracking/integration-payloads.json
Expand Up @@ -39,7 +39,7 @@
"master": {
"uncompressed": {
"runtime-es2015": 2285,
"main-es2015": 241843,
"main-es2015": 242531,
"polyfills-es2015": 36709,
"5-es2015": 745
}
Expand All @@ -49,7 +49,7 @@
"master": {
"uncompressed": {
"runtime-es2015": 2289,
"main-es2015": 217591,
"main-es2015": 218317,
"polyfills-es2015": 36723,
"5-es2015": 781
}
Expand Down
15 changes: 15 additions & 0 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Expand Up @@ -1199,6 +1199,9 @@
{
"name": "equalPath"
},
{
"name": "exactMatchOptions"
},
{
"name": "executeCheckHooks"
},
Expand Down Expand Up @@ -1658,6 +1661,9 @@
{
"name": "materializeViewResults"
},
{
"name": "matrixParamsMatch"
},
{
"name": "maybeUnwrapFn"
},
Expand Down Expand Up @@ -1751,6 +1757,12 @@
{
"name": "optionsReducer"
},
{
"name": "paramCompareMap"
},
{
"name": "pathCompareMap"
},
{
"name": "pipeFromArray"
},
Expand Down Expand Up @@ -1946,6 +1958,9 @@
{
"name": "subscribeToResult"
},
{
"name": "subsetMatchOptions"
},
{
"name": "supportsState"
},
Expand Down
20 changes: 16 additions & 4 deletions packages/router/src/directives/router_link_active.ts
Expand Up @@ -12,6 +12,7 @@ import {mergeAll} from 'rxjs/operators';

import {Event, NavigationEnd} from '../events';
import {Router} from '../router';
import {IsActiveMatchOptions} from '../url_tree';

import {RouterLink, RouterLinkWithHref} from './router_link';

Expand Down Expand Up @@ -89,7 +90,15 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
private linkInputChangesSubscription?: Subscription;
public readonly isActive: boolean = false;

@Input() routerLinkActiveOptions: {exact: boolean} = {exact: false};
/**
* Options to configure how to determine if the router link is active.
*
* These options are passed to the `Router.isActive()` function.
*
* @see Router.isActive
*/
@Input() routerLinkActiveOptions: {exact: boolean}|IsActiveMatchOptions = {exact: false};


constructor(
private router: Router, private element: ElementRef, private renderer: Renderer2,
Expand Down Expand Up @@ -159,8 +168,11 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
}

private isLinkActive(router: Router): (link: (RouterLink|RouterLinkWithHref)) => boolean {
return (link: RouterLink|RouterLinkWithHref) =>
router.isActive(link.urlTree, this.routerLinkActiveOptions.exact);
const options = 'paths' in this.routerLinkActiveOptions ?
this.routerLinkActiveOptions :
// While the types should disallow `undefined` here, it's possible without strict inputs
(this.routerLinkActiveOptions.exact || false);
return (link: RouterLink|RouterLinkWithHref) => router.isActive(link.urlTree, options);
}

private hasActiveLinks(): boolean {
Expand All @@ -169,4 +181,4 @@ export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit
this.linkWithHref && isActiveCheckFn(this.linkWithHref) ||
this.links.some(isActiveCheckFn) || this.linksWithHrefs.some(isActiveCheckFn);
}
}
}
2 changes: 1 addition & 1 deletion packages/router/src/index.ts
Expand Up @@ -22,7 +22,7 @@ export {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} fr
export {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot} from './router_state';
export {convertToParamMap, ParamMap, Params, PRIMARY_OUTLET} from './shared';
export {UrlHandlingStrategy} from './url_handling_strategy';
export {DefaultUrlSerializer, UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
export {DefaultUrlSerializer, IsActiveMatchOptions, UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from './url_tree';
export {VERSION} from './version';

export * from './private_export';
58 changes: 53 additions & 5 deletions packages/router/src/router.ts
Expand Up @@ -27,7 +27,7 @@ import {ChildrenOutletContexts} from './router_outlet_context';
import {ActivatedRoute, createEmptyState, RouterState, RouterStateSnapshot} from './router_state';
import {isNavigationCancelingError, navigationCancelingError, Params} from './shared';
import {DefaultUrlHandlingStrategy, UrlHandlingStrategy} from './url_handling_strategy';
import {containsTree, createEmptyUrlTree, UrlSerializer, UrlTree} from './url_tree';
import {containsTree, createEmptyUrlTree, IsActiveMatchOptions, UrlSerializer, UrlTree} from './url_tree';
import {standardizeConfig, validateConfig} from './utils/config';
import {Checks, getAllRouteGuards} from './utils/preactivation';
import {isUrlTree} from './utils/type_guards';
Expand Down Expand Up @@ -356,6 +356,29 @@ type LocationChangeInfo = {
transitionId: number
};

/**
* The equivalent `IsActiveUrlTreeOptions` options for `Router.isActive` is called with `false`
* (exact = true).
*/
export const exactMatchOptions: IsActiveMatchOptions = {
paths: 'exact',
fragment: 'ignored',
matrixParams: 'ignored',
queryParams: 'exact'
};

/**
* The equivalent `IsActiveUrlTreeOptions` options for `Router.isActive` is called with `false`
* (exact = false).
*/
export const subsetMatchOptions: IsActiveMatchOptions = {
paths: 'subset',
fragment: 'ignored',
matrixParams: 'ignored',
queryParams: 'subset'
};


/**
* @description
*
Expand Down Expand Up @@ -1213,14 +1236,39 @@ export class Router {
return urlTree;
}

/** Returns whether the url is activated */
isActive(url: string|UrlTree, exact: boolean): boolean {
/**
* Returns whether the url is activated.
*
* @deprecated
* Use `IsActiveUrlTreeOptions` instead.
*
* - The equivalent `IsActiveUrlTreeOptions` for `true` is
* `{paths: 'exact', queryParams: 'exact', fragment: 'ignored', matrixParams: 'ignored'}`.
* - The equivalent for `false` is
* `{paths: 'subset', queryParams: 'subset', fragment: 'ignored', matrixParams: 'ignored'}`.
*/
isActive(url: string|UrlTree, exact: boolean): boolean;
/**
* Returns whether the url is activated.
*/
isActive(url: string|UrlTree, matchOptions: IsActiveMatchOptions): boolean;
/** @internal */
isActive(url: string|UrlTree, matchOptions: boolean|IsActiveMatchOptions): boolean;
isActive(url: string|UrlTree, matchOptions: boolean|IsActiveMatchOptions): boolean {
let options: IsActiveMatchOptions;
if (matchOptions === true) {
options = {...exactMatchOptions};
} else if (matchOptions === false) {
options = {...subsetMatchOptions};
} else {
options = matchOptions;
}
if (isUrlTree(url)) {
return containsTree(this.currentUrlTree, url, exact);
return containsTree(this.currentUrlTree, url, options);
}

const urlTree = this.parseUrl(url);
return containsTree(this.currentUrlTree, urlTree, exact);
return containsTree(this.currentUrlTree, urlTree, options);
}

private removeEmptyProps(params: Params): Params {
Expand Down
116 changes: 100 additions & 16 deletions packages/router/src/url_tree.ts
Expand Up @@ -13,65 +13,149 @@ export function createEmptyUrlTree() {
return new UrlTree(new UrlSegmentGroup([], {}), {}, null);
}

export function containsTree(container: UrlTree, containee: UrlTree, exact: boolean): boolean {
if (exact) {
return equalQueryParams(container.queryParams, containee.queryParams) &&
equalSegmentGroups(container.root, containee.root);
}
/**
* A set of options which specify how to determine if a `UrlTree` is active, given the `UrlTree`
* for the current router state.
*
* @publicApi
* @see Router.isActive
*/
export interface IsActiveMatchOptions {
/**
* Defines the strategy for comparing the matrix parameters of two `UrlTree`s.
*
* The matrix parameter matching is dependent on the strategy for matching the
* segments. That is, if the `paths` option is set to `'subset'`, only
* the matrix parameters of the matching segments will be compared.
*
* - `'exact'`: Requires that matching segments also have exact matrix parameter
* matches.
* - `'subset'`: The matching segments in the router's active `UrlTree` may contain
* extra matrix parameters, but those that exist in the `UrlTree` in question must match.
atscott marked this conversation as resolved.
Show resolved Hide resolved
* - `'ignored'`: When comparing `UrlTree`s, matrix params will be ignored.
*/
matrixParams: 'exact'|'subset'|'ignored';
/**
* Defines the strategy for comparing the query parameters of two `UrlTree`s.
*
* - `'exact'`: the query parameters must match exactly.
* - `'subset'`: the active `UrlTree` may contain extra parameters,
atscott marked this conversation as resolved.
Show resolved Hide resolved
* but must match the key and value of any that exist in the `UrlTree` in question.
* - `'ignored'`: When comparing `UrlTree`s, query params will be ignored.
*/
queryParams: 'exact'|'subset'|'ignored';
/**
* Defines the strategy for comparing the `UrlSegment`s of the `UrlTree`s.
*
* - `'exact'`: all segments in each `UrlTree` must match.
* - `'subset'`: a `UrlTree` will be determined to be active if it
* is a subtree of the active route. That is, the active route may contain extra
* segments, but must at least have all the segements of the `UrlTree` in question.
*/
paths: 'exact'|'subset';
/**
* - 'exact'`: indicates that the `UrlTree` fragments must be equal.
* - `'ignored'`: the fragments will not be compared when determining if a
* `UrlTree` is active.
*/
fragment: 'exact'|'ignored';
}

return containsQueryParams(container.queryParams, containee.queryParams) &&
containsSegmentGroup(container.root, containee.root);
type ParamMatchOptions = 'exact'|'subset'|'ignored';

type PathCompareFn =
(container: UrlSegmentGroup, containee: UrlSegmentGroup, matrixParams: ParamMatchOptions) =>
boolean;
type ParamCompareFn = (container: Params, containee: Params) => boolean;

const pathCompareMap: Record<IsActiveMatchOptions['paths'], PathCompareFn> = {
'exact': equalSegmentGroups,
'subset': containsSegmentGroup,
};
const paramCompareMap: Record<ParamMatchOptions, ParamCompareFn> = {
'exact': equalParams,
'subset': containsParams,
'ignored': () => true,
};

export function containsTree(
container: UrlTree, containee: UrlTree, options: IsActiveMatchOptions): boolean {
return pathCompareMap[options.paths](container.root, containee.root, options.matrixParams) &&
paramCompareMap[options.queryParams](container.queryParams, containee.queryParams) &&
!(options.fragment === 'exact' && container.fragment !== containee.fragment);
}

function equalQueryParams(container: Params, containee: Params): boolean {
function equalParams(container: Params, containee: Params): boolean {
// TODO: This does not handle array params correctly.
return shallowEqual(container, containee);
}

function equalSegmentGroups(container: UrlSegmentGroup, containee: UrlSegmentGroup): boolean {
function equalSegmentGroups(
container: UrlSegmentGroup, containee: UrlSegmentGroup,
matrixParams: ParamMatchOptions): boolean {
if (!equalPath(container.segments, containee.segments)) return false;
if (!matrixParamsMatch(container.segments, containee.segments, matrixParams)) {
return false;
}
if (container.numberOfChildren !== containee.numberOfChildren) return false;
for (const c in containee.children) {
if (!container.children[c]) return false;
if (!equalSegmentGroups(container.children[c], containee.children[c])) return false;
if (!equalSegmentGroups(container.children[c], containee.children[c], matrixParams))
return false;
}
return true;
}

function containsQueryParams(container: Params, containee: Params): boolean {
function containsParams(container: Params, containee: Params): boolean {
return Object.keys(containee).length <= Object.keys(container).length &&
Object.keys(containee).every(key => equalArraysOrString(container[key], containee[key]));
}

function containsSegmentGroup(container: UrlSegmentGroup, containee: UrlSegmentGroup): boolean {
return containsSegmentGroupHelper(container, containee, containee.segments);
function containsSegmentGroup(
container: UrlSegmentGroup, containee: UrlSegmentGroup,
matrixParams: ParamMatchOptions): boolean {
return containsSegmentGroupHelper(container, containee, containee.segments, matrixParams);
}

function containsSegmentGroupHelper(
container: UrlSegmentGroup, containee: UrlSegmentGroup, containeePaths: UrlSegment[]): boolean {
container: UrlSegmentGroup, containee: UrlSegmentGroup, containeePaths: UrlSegment[],
matrixParams: ParamMatchOptions): boolean {
if (container.segments.length > containeePaths.length) {
const current = container.segments.slice(0, containeePaths.length);
if (!equalPath(current, containeePaths)) return false;
if (containee.hasChildren()) return false;
if (!matrixParamsMatch(current, containeePaths, matrixParams)) return false;
return true;

} else if (container.segments.length === containeePaths.length) {
if (!equalPath(container.segments, containeePaths)) return false;
if (!matrixParamsMatch(container.segments, containeePaths, matrixParams)) return false;
for (const c in containee.children) {
if (!container.children[c]) return false;
if (!containsSegmentGroup(container.children[c], containee.children[c])) return false;
if (!containsSegmentGroup(container.children[c], containee.children[c], matrixParams)) {
return false;
}
}
return true;

} else {
const current = containeePaths.slice(0, container.segments.length);
const next = containeePaths.slice(container.segments.length);
if (!equalPath(container.segments, current)) return false;
if (!matrixParamsMatch(container.segments, current, matrixParams)) return false;
if (!container.children[PRIMARY_OUTLET]) return false;
return containsSegmentGroupHelper(container.children[PRIMARY_OUTLET], containee, next);
return containsSegmentGroupHelper(
container.children[PRIMARY_OUTLET], containee, next, matrixParams);
}
}

function matrixParamsMatch(
containerPaths: UrlSegment[], containeePaths: UrlSegment[], options: ParamMatchOptions) {
return containeePaths.every((containeeSegment, i) => {
return paramCompareMap[options](containerPaths[i].parameters, containeeSegment.parameters);
});
}

/**
* @description
*
Expand Down